Merge branch 'develop' into promotion-enhancements
This commit is contained in:
commit
ed04241cf4
@ -26,3 +26,6 @@ b147b85e6ac19a9220cd1e2958a6ebd99373283a
|
||||
|
||||
# bulk format python code with black
|
||||
494bd9ef78313436f0424b918f200dab8fc7c20b
|
||||
|
||||
# bulk format python code with black
|
||||
baec607ff5905b1c67531096a9cf50ec7ff00a5d
|
31
.github/workflows/release.yml
vendored
Normal file
31
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: Generate Semantic Release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- version-13
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Entire Repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Setup Node.js v14
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
- name: Setup dependencies
|
||||
run: |
|
||||
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GIT_AUTHOR_NAME: "Frappe PR Bot"
|
||||
GIT_AUTHOR_EMAIL: "developers@frappe.io"
|
||||
GIT_COMMITTER_NAME: "Frappe PR Bot"
|
||||
GIT_COMMITTER_EMAIL: "developers@frappe.io"
|
||||
run: npx semantic-release
|
18
.github/workflows/server-tests-mariadb.yml
vendored
18
.github/workflows/server-tests-mariadb.yml
vendored
@ -118,10 +118,26 @@ jobs:
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-${{ matrix.container }}
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
name: MariaDB
|
||||
fail_ci_if_error: true
|
||||
files: /home/runner/frappe-bench/sites/coverage.xml
|
||||
verbose: true
|
||||
|
34
.mergify.yml
34
.mergify.yml
@ -88,3 +88,37 @@ pull_request_rules:
|
||||
- version-12-pre-release
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: Automatic merge on CI success and review
|
||||
conditions:
|
||||
- status-success=linters
|
||||
- status-success=Sider
|
||||
- status-success=Semantic Pull Request
|
||||
- status-success=Patch Test
|
||||
- status-success=Python Unit Tests (1)
|
||||
- status-success=Python Unit Tests (2)
|
||||
- status-success=Python Unit Tests (3)
|
||||
- label!=dont-merge
|
||||
- label!=squash
|
||||
- "#approved-reviews-by>=1"
|
||||
actions:
|
||||
merge:
|
||||
method: merge
|
||||
- name: Automatic squash on CI success and review
|
||||
conditions:
|
||||
- status-success=linters
|
||||
- status-success=Sider
|
||||
- status-success=Patch Test
|
||||
- status-success=Python Unit Tests (1)
|
||||
- status-success=Python Unit Tests (2)
|
||||
- status-success=Python Unit Tests (3)
|
||||
- label!=dont-merge
|
||||
- label=squash
|
||||
- "#approved-reviews-by>=1"
|
||||
actions:
|
||||
merge:
|
||||
method: squash
|
||||
commit_message_template: |
|
||||
{{ title }} (#{{ number }})
|
||||
|
||||
{{ body }}
|
||||
|
24
.releaserc
Normal file
24
.releaserc
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"branches": ["version-13"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer", {
|
||||
"preset": "angular",
|
||||
"releaseRules": [
|
||||
{"breaking": true, "release": false}
|
||||
]
|
||||
},
|
||||
"@semantic-release/release-notes-generator",
|
||||
[
|
||||
"@semantic-release/exec", {
|
||||
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" erpnext/__init__.py'
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git", {
|
||||
"assets": ["erpnext/__init__.py"],
|
||||
"message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}"
|
||||
}
|
||||
],
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
@ -21,7 +21,6 @@ coverage:
|
||||
comment:
|
||||
layout: "diff, files"
|
||||
require_changes: true
|
||||
after_n_builds: 3
|
||||
|
||||
ignore:
|
||||
- "erpnext/demo"
|
||||
|
@ -386,7 +386,6 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None):
|
||||
doc,
|
||||
credit_account,
|
||||
debit_account,
|
||||
against,
|
||||
amount,
|
||||
base_amount,
|
||||
end_date,
|
||||
@ -570,7 +569,6 @@ def book_revenue_via_journal_entry(
|
||||
doc,
|
||||
credit_account,
|
||||
debit_account,
|
||||
against,
|
||||
amount,
|
||||
base_amount,
|
||||
posting_date,
|
||||
@ -591,6 +589,7 @@ def book_revenue_via_journal_entry(
|
||||
journal_entry.voucher_type = (
|
||||
"Deferred Revenue" if doc.doctype == "Sales Invoice" else "Deferred Expense"
|
||||
)
|
||||
journal_entry.process_deferred_accounting = deferred_process
|
||||
|
||||
debit_entry = {
|
||||
"account": credit_account,
|
||||
|
@ -205,10 +205,16 @@ def get_doctypes_with_dimensions():
|
||||
return frappe.get_hooks("accounting_dimension_doctypes")
|
||||
|
||||
|
||||
def get_accounting_dimensions(as_list=True):
|
||||
def get_accounting_dimensions(as_list=True, filters=None):
|
||||
|
||||
if not filters:
|
||||
filters = {"disabled": 0}
|
||||
|
||||
if frappe.flags.accounting_dimensions is None:
|
||||
frappe.flags.accounting_dimensions = frappe.get_all(
|
||||
"Accounting Dimension", fields=["label", "fieldname", "disabled", "document_type"]
|
||||
"Accounting Dimension",
|
||||
fields=["label", "fieldname", "disabled", "document_type"],
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
if as_list:
|
||||
|
@ -11,6 +11,8 @@ from frappe.utils import nowdate
|
||||
class CurrencyExchangeSettings(Document):
|
||||
def validate(self):
|
||||
self.set_parameters_and_result()
|
||||
if frappe.flags.in_test or frappe.flags.in_install or frappe.flags.in_setup_wizard:
|
||||
return
|
||||
response, value = self.validate_parameters()
|
||||
self.validate_result(response, value)
|
||||
|
||||
@ -35,9 +37,6 @@ class CurrencyExchangeSettings(Document):
|
||||
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
|
||||
|
||||
def validate_parameters(self):
|
||||
if frappe.flags.in_test:
|
||||
return None, None
|
||||
|
||||
params = {}
|
||||
for row in self.req_params:
|
||||
params[row.key] = row.value.format(
|
||||
@ -59,9 +58,6 @@ class CurrencyExchangeSettings(Document):
|
||||
return response, value
|
||||
|
||||
def validate_result(self, response, value):
|
||||
if frappe.flags.in_test:
|
||||
return
|
||||
|
||||
try:
|
||||
for key in self.result_key:
|
||||
value = value[
|
||||
|
@ -3,6 +3,6 @@
|
||||
|
||||
frappe.ui.form.on('GL Entry', {
|
||||
refresh: function(frm) {
|
||||
|
||||
frm.page.btn_secondary.hide()
|
||||
}
|
||||
});
|
||||
|
@ -269,6 +269,11 @@ class GLEntry(Document):
|
||||
if not self.fiscal_year:
|
||||
self.fiscal_year = get_fiscal_year(self.posting_date, company=self.company)[0]
|
||||
|
||||
def on_cancel(self):
|
||||
msg = _("Individual GL Entry cannot be cancelled.")
|
||||
msg += "<br>" + _("Please cancel related transaction.")
|
||||
frappe.throw(msg)
|
||||
|
||||
|
||||
def validate_balance_type(account, adv_adj=False):
|
||||
if not adv_adj and account:
|
||||
|
@ -10,6 +10,7 @@
|
||||
"sgst_account",
|
||||
"igst_account",
|
||||
"cess_account",
|
||||
"utgst_account",
|
||||
"is_reverse_charge_account"
|
||||
],
|
||||
"fields": [
|
||||
@ -64,12 +65,18 @@
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Is Reverse Charge Account"
|
||||
},
|
||||
{
|
||||
"fieldname": "utgst_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "UTGST Account",
|
||||
"options": "Account"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-04-09 12:30:25.889993",
|
||||
"modified": "2022-04-07 12:59:14.039768",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "GST Account",
|
||||
@ -78,5 +85,6 @@
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
"allow_auto_repeat": 1,
|
||||
"allow_import": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2013-03-25 10:53:52",
|
||||
"creation": "2022-01-25 10:29:58.717206",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"engine": "InnoDB",
|
||||
@ -13,6 +13,7 @@
|
||||
"voucher_type",
|
||||
"naming_series",
|
||||
"finance_book",
|
||||
"process_deferred_accounting",
|
||||
"reversal_of",
|
||||
"tax_withholding_category",
|
||||
"column_break1",
|
||||
@ -524,13 +525,20 @@
|
||||
"label": "Reversal Of",
|
||||
"options": "Journal Entry",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "process_deferred_accounting",
|
||||
"fieldtype": "Link",
|
||||
"label": "Process Deferred Accounting",
|
||||
"options": "Process Deferred Accounting",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-file-text",
|
||||
"idx": 176,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-01-04 13:39:36.485954",
|
||||
"modified": "2022-04-06 17:18:46.865259",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Journal Entry",
|
||||
@ -578,6 +586,7 @@
|
||||
"search_fields": "voucher_type,posting_date, due_date, cheque_no",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
@ -18,7 +18,6 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
|
||||
)
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import (
|
||||
check_if_stock_and_account_balance_synced,
|
||||
get_account_currency,
|
||||
get_balance_on,
|
||||
get_stock_accounts,
|
||||
@ -88,9 +87,6 @@ class JournalEntry(AccountsController):
|
||||
self.update_inter_company_jv()
|
||||
self.update_invoice_discounting()
|
||||
self.update_status_for_full_and_final_statement()
|
||||
check_if_stock_and_account_balance_synced(
|
||||
self.posting_date, self.company, self.doctype, self.name
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries
|
||||
|
@ -224,10 +224,7 @@ frappe.ui.form.on('Payment Entry', {
|
||||
(frm.doc.total_allocated_amount > party_amount)));
|
||||
|
||||
frm.toggle_display("set_exchange_gain_loss",
|
||||
(frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount &&
|
||||
((frm.doc.paid_from_account_currency != company_currency ||
|
||||
frm.doc.paid_to_account_currency != company_currency) &&
|
||||
frm.doc.paid_from_account_currency != frm.doc.paid_to_account_currency)));
|
||||
frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount);
|
||||
|
||||
frm.refresh_fields();
|
||||
},
|
||||
|
@ -350,9 +350,13 @@ class PaymentReconciliation(Document):
|
||||
)
|
||||
|
||||
if self.minimum_invoice_amount:
|
||||
condition += " and `{0}` >= {1}".format(dr_or_cr, flt(self.minimum_invoice_amount))
|
||||
condition += " and {dr_or_cr} >= {amount}".format(
|
||||
dr_or_cr=dr_or_cr, amount=flt(self.minimum_invoice_amount)
|
||||
)
|
||||
if self.maximum_invoice_amount:
|
||||
condition += " and `{0}` <= {1}".format(dr_or_cr, flt(self.maximum_invoice_amount))
|
||||
condition += " and {dr_or_cr} <= {amount}".format(
|
||||
dr_or_cr=dr_or_cr, amount=flt(self.maximum_invoice_amount)
|
||||
)
|
||||
|
||||
elif get_return_invoices:
|
||||
condition = " and doc.company = '{0}' ".format(self.company)
|
||||
@ -367,15 +371,19 @@ class PaymentReconciliation(Document):
|
||||
else ""
|
||||
)
|
||||
dr_or_cr = (
|
||||
"gl.debit_in_account_currency"
|
||||
"debit_in_account_currency"
|
||||
if erpnext.get_party_account_type(self.party_type) == "Receivable"
|
||||
else "gl.credit_in_account_currency"
|
||||
else "credit_in_account_currency"
|
||||
)
|
||||
|
||||
if self.minimum_invoice_amount:
|
||||
condition += " and `{0}` >= {1}".format(dr_or_cr, flt(self.minimum_payment_amount))
|
||||
condition += " and gl.{dr_or_cr} >= {amount}".format(
|
||||
dr_or_cr=dr_or_cr, amount=flt(self.minimum_payment_amount)
|
||||
)
|
||||
if self.maximum_invoice_amount:
|
||||
condition += " and `{0}` <= {1}".format(dr_or_cr, flt(self.maximum_payment_amount))
|
||||
condition += " and gl.{dr_or_cr} <= {amount}".format(
|
||||
dr_or_cr=dr_or_cr, amount=flt(self.maximum_payment_amount)
|
||||
)
|
||||
|
||||
else:
|
||||
condition += (
|
||||
|
@ -35,10 +35,11 @@ class PricingRule(Document):
|
||||
self.margin_rate_or_amount = 0.0
|
||||
|
||||
def validate_duplicate_apply_on(self):
|
||||
field = apply_on_dict.get(self.apply_on)
|
||||
values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field]
|
||||
if len(values) != len(set(values)):
|
||||
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
|
||||
if self.apply_on != "Transaction":
|
||||
field = apply_on_dict.get(self.apply_on)
|
||||
values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field]
|
||||
if len(values) != len(set(values)):
|
||||
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
|
||||
|
||||
def validate_mandatory(self):
|
||||
for apply_on, field in apply_on_dict.items():
|
||||
|
@ -11,7 +11,7 @@ from erpnext.accounts.deferred_revenue import (
|
||||
convert_deferred_expense_to_expense,
|
||||
convert_deferred_revenue_to_income,
|
||||
)
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
from erpnext.accounts.general_ledger import make_gl_entries
|
||||
|
||||
|
||||
class ProcessDeferredAccounting(Document):
|
||||
@ -34,4 +34,4 @@ class ProcessDeferredAccounting(Document):
|
||||
filters={"against_voucher_type": self.doctype, "against_voucher": self.name},
|
||||
)
|
||||
|
||||
make_reverse_gl_entries(gl_entries=gl_entries)
|
||||
make_gl_entries(gl_entries=gl_entries, cancel=1)
|
||||
|
@ -34,8 +34,9 @@ class ProcessStatementOfAccounts(Document):
|
||||
frappe.throw(_("Customers not selected."))
|
||||
|
||||
if self.enable_auto_email:
|
||||
self.to_date = self.start_date
|
||||
self.from_date = add_months(self.to_date, -1 * self.filter_duration)
|
||||
if self.start_date and getdate(self.start_date) >= getdate(today()):
|
||||
self.to_date = self.start_date
|
||||
self.from_date = add_months(self.to_date, -1 * self.filter_duration)
|
||||
|
||||
|
||||
def get_report_pdf(doc, consolidated=True):
|
||||
|
@ -30,6 +30,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
onload() {
|
||||
super.onload();
|
||||
|
||||
// Ignore linked advances
|
||||
this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry'];
|
||||
|
||||
if(!this.frm.doc.__islocal) {
|
||||
// show credit_to in print format
|
||||
if(!this.frm.doc.supplier && this.frm.doc.credit_to) {
|
||||
|
@ -811,7 +811,9 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
if provisional_accounting_for_non_stock_items:
|
||||
if item.purchase_receipt:
|
||||
provisional_account = self.get_company_default("default_provisional_account")
|
||||
provisional_account = frappe.db.get_value(
|
||||
"Purchase Receipt Item", item.pr_detail, "provisional_expense_account"
|
||||
) or self.get_company_default("default_provisional_account")
|
||||
purchase_receipt_doc = purchase_receipt_doc_map.get(item.purchase_receipt)
|
||||
|
||||
if not purchase_receipt_doc:
|
||||
@ -834,7 +836,7 @@ class PurchaseInvoice(BuyingController):
|
||||
if expense_booked_in_pr:
|
||||
# Intentionally passing purchase invoice item to handle partial billing
|
||||
purchase_receipt_doc.add_provisional_gl_entry(
|
||||
item, gl_entries, self.posting_date, reverse=1
|
||||
item, gl_entries, self.posting_date, provisional_account, reverse=1
|
||||
)
|
||||
|
||||
if not self.is_internal_transfer():
|
||||
|
@ -1482,7 +1482,8 @@ class TestPurchaseInvoice(unittest.TestCase):
|
||||
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0)
|
||||
|
||||
def test_provisional_accounting_entry(self):
|
||||
item = create_item("_Test Non Stock Item", is_stock_item=0)
|
||||
create_item("_Test Non Stock Item", is_stock_item=0)
|
||||
|
||||
provisional_account = create_account(
|
||||
account_name="Provision Account",
|
||||
parent_account="Current Liabilities - _TC",
|
||||
@ -1505,6 +1506,8 @@ class TestPurchaseInvoice(unittest.TestCase):
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
self.assertEquals(pr.items[0].provisional_expense_account, "Provision Account - _TC")
|
||||
|
||||
# Check GLE for Purchase Invoice
|
||||
expected_gle = [
|
||||
["Cost of Goods Sold - _TC", 250, 0, add_days(pr.posting_date, -1)],
|
||||
|
@ -33,7 +33,9 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
|
||||
var me = this;
|
||||
super.onload();
|
||||
|
||||
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', 'POS Closing Entry'];
|
||||
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
|
||||
'POS Closing Entry', 'Journal Entry', 'Payment Entry'];
|
||||
|
||||
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
|
||||
// show debit_to in print format
|
||||
this.frm.set_df_property("debit_to", "print_hide", 0);
|
||||
|
@ -2240,6 +2240,14 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
|
||||
check_gl_entries(self, si.name, expected_gle, "2019-01-30")
|
||||
|
||||
def test_deferred_revenue_missing_account(self):
|
||||
si = create_sales_invoice(posting_date="2019-01-10", do_not_submit=True)
|
||||
si.items[0].enable_deferred_revenue = 1
|
||||
si.items[0].service_start_date = "2019-01-10"
|
||||
si.items[0].service_end_date = "2019-03-15"
|
||||
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
|
||||
def test_fixed_deferred_revenue(self):
|
||||
deferred_account = create_account(
|
||||
account_name="Deferred Revenue",
|
||||
@ -3104,7 +3112,7 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
|
||||
acc_settings = frappe.get_single("Accounts Settings")
|
||||
acc_settings.book_deferred_entries_via_journal_entry = 0
|
||||
acc_settings.submit_journal_entriessubmit_journal_entries = 0
|
||||
acc_settings.submit_journal_entries = 0
|
||||
acc_settings.save()
|
||||
|
||||
frappe.db.set_value("Accounts Settings", None, "acc_frozen_upto", None)
|
||||
@ -3116,6 +3124,62 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
si.reload()
|
||||
self.assertTrue(si.items[0].serial_no)
|
||||
|
||||
def test_gain_loss_with_advance_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
unlink_enabled = frappe.db.get_value(
|
||||
"Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice"
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", 1
|
||||
)
|
||||
|
||||
jv = make_journal_entry("_Test Receivable USD - _TC", "_Test Bank - _TC", -7000, save=False)
|
||||
|
||||
jv.accounts[0].exchange_rate = 70
|
||||
jv.accounts[0].credit_in_account_currency = 100
|
||||
jv.accounts[0].party_type = "Customer"
|
||||
jv.accounts[0].party = "_Test Customer USD"
|
||||
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
si = create_sales_invoice(
|
||||
customer="_Test Customer USD",
|
||||
debit_to="_Test Receivable USD - _TC",
|
||||
currency="USD",
|
||||
conversion_rate=75,
|
||||
do_not_save=1,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
si.append(
|
||||
"advances",
|
||||
{
|
||||
"reference_type": "Journal Entry",
|
||||
"reference_name": jv.name,
|
||||
"reference_row": jv.accounts[0].name,
|
||||
"advance_amount": 100,
|
||||
"allocated_amount": 100,
|
||||
"ref_exchange_rate": 70,
|
||||
},
|
||||
)
|
||||
si.save()
|
||||
si.submit()
|
||||
|
||||
expected_gle = [
|
||||
["_Test Receivable USD - _TC", 7500.0, 500],
|
||||
["Exchange Gain/Loss - _TC", 500.0, 0.0],
|
||||
["Sales - _TC", 0.0, 7500.0],
|
||||
]
|
||||
|
||||
check_gl_entries(self, si.name, expected_gle, nowdate())
|
||||
|
||||
frappe.db.set_value(
|
||||
"Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled
|
||||
)
|
||||
|
||||
|
||||
def get_sales_invoice_for_e_invoice():
|
||||
si = make_sales_invoice_for_ewaybill()
|
||||
|
@ -124,11 +124,10 @@ def get_columns(invoice_list, additional_table_columns):
|
||||
_("Purchase Receipt") + ":Link/Purchase Receipt:100",
|
||||
{"fieldname": "currency", "label": _("Currency"), "fieldtype": "Data", "width": 80},
|
||||
]
|
||||
expense_accounts = (
|
||||
tax_accounts
|
||||
) = (
|
||||
expense_columns
|
||||
) = tax_columns = unrealized_profit_loss_accounts = unrealized_profit_loss_account_columns = []
|
||||
|
||||
expense_accounts = []
|
||||
tax_accounts = []
|
||||
unrealized_profit_loss_accounts = []
|
||||
|
||||
if invoice_list:
|
||||
expense_accounts = frappe.db.sql_list(
|
||||
@ -163,10 +162,11 @@ def get_columns(invoice_list, additional_table_columns):
|
||||
unrealized_profit_loss_account_columns = [
|
||||
(account + ":Currency/currency:120") for account in unrealized_profit_loss_accounts
|
||||
]
|
||||
|
||||
for account in tax_accounts:
|
||||
if account not in expense_accounts:
|
||||
tax_columns.append(account + ":Currency/currency:120")
|
||||
tax_columns = [
|
||||
(account + ":Currency/currency:120")
|
||||
for account in tax_accounts
|
||||
if account not in expense_accounts
|
||||
]
|
||||
|
||||
columns = (
|
||||
columns
|
||||
|
@ -1,10 +1,17 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.test_runner import make_test_objects
|
||||
|
||||
from erpnext.accounts.party import get_party_shipping_address
|
||||
from erpnext.accounts.utils import get_future_stock_vouchers, get_voucherwise_gl_entries
|
||||
from erpnext.accounts.utils import (
|
||||
get_future_stock_vouchers,
|
||||
get_voucherwise_gl_entries,
|
||||
sort_stock_vouchers_by_posting_date,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
@ -47,6 +54,25 @@ class TestUtils(unittest.TestCase):
|
||||
msg="get_voucherwise_gl_entries not returning expected GLes",
|
||||
)
|
||||
|
||||
def test_stock_voucher_sorting(self):
|
||||
vouchers = []
|
||||
|
||||
item = make_item().name
|
||||
|
||||
stock_entry = {"item": item, "to_warehouse": "_Test Warehouse - _TC", "qty": 1, "rate": 10}
|
||||
|
||||
se1 = make_stock_entry(posting_date="2022-01-01", **stock_entry)
|
||||
se2 = make_stock_entry(posting_date="2022-02-01", **stock_entry)
|
||||
se3 = make_stock_entry(posting_date="2022-03-01", **stock_entry)
|
||||
|
||||
for doc in (se1, se2, se3):
|
||||
vouchers.append((doc.doctype, doc.name))
|
||||
|
||||
vouchers.append(("Stock Entry", "Wat"))
|
||||
|
||||
sorted_vouchers = sort_stock_vouchers_by_posting_date(list(reversed(vouchers)))
|
||||
self.assertEqual(sorted_vouchers, vouchers)
|
||||
|
||||
|
||||
ADDRESS_RECORDS = [
|
||||
{
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
from json import loads
|
||||
from typing import List, Tuple
|
||||
|
||||
import frappe
|
||||
import frappe.defaults
|
||||
@ -18,10 +19,6 @@ from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock.utils import get_stock_value_on
|
||||
|
||||
|
||||
class StockValueAndAccountBalanceOutOfSync(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class FiscalYearError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
@ -1126,6 +1123,9 @@ def update_gl_entries_after(
|
||||
def repost_gle_for_stock_vouchers(
|
||||
stock_vouchers, posting_date, company=None, warehouse_account=None
|
||||
):
|
||||
if not stock_vouchers:
|
||||
return
|
||||
|
||||
def _delete_gl_entries(voucher_type, voucher_no):
|
||||
frappe.db.sql(
|
||||
"""delete from `tabGL Entry`
|
||||
@ -1133,6 +1133,8 @@ def repost_gle_for_stock_vouchers(
|
||||
(voucher_type, voucher_no),
|
||||
)
|
||||
|
||||
stock_vouchers = sort_stock_vouchers_by_posting_date(stock_vouchers)
|
||||
|
||||
if not warehouse_account:
|
||||
warehouse_account = get_warehouse_account_map(company)
|
||||
|
||||
@ -1153,6 +1155,27 @@ def repost_gle_for_stock_vouchers(
|
||||
_delete_gl_entries(voucher_type, voucher_no)
|
||||
|
||||
|
||||
def sort_stock_vouchers_by_posting_date(
|
||||
stock_vouchers: List[Tuple[str, str]]
|
||||
) -> List[Tuple[str, str]]:
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
voucher_nos = [v[1] for v in stock_vouchers]
|
||||
|
||||
sles = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
|
||||
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
|
||||
.groupby(sle.voucher_type, sle.voucher_no)
|
||||
).run(as_dict=True)
|
||||
sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles]
|
||||
|
||||
unknown_vouchers = set(stock_vouchers) - set(sorted_vouchers)
|
||||
if unknown_vouchers:
|
||||
sorted_vouchers.extend(unknown_vouchers)
|
||||
|
||||
return sorted_vouchers
|
||||
|
||||
|
||||
def get_future_stock_vouchers(
|
||||
posting_date, posting_time, for_warehouses=None, for_items=None, company=None
|
||||
):
|
||||
@ -1246,47 +1269,6 @@ def compare_existing_and_expected_gle(existing_gle, expected_gle, precision):
|
||||
return matched
|
||||
|
||||
|
||||
def check_if_stock_and_account_balance_synced(
|
||||
posting_date, company, voucher_type=None, voucher_no=None
|
||||
):
|
||||
if not cint(erpnext.is_perpetual_inventory_enabled(company)):
|
||||
return
|
||||
|
||||
accounts = get_stock_accounts(company, voucher_type, voucher_no)
|
||||
stock_adjustment_account = frappe.db.get_value("Company", company, "stock_adjustment_account")
|
||||
|
||||
for account in accounts:
|
||||
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
|
||||
account, posting_date, company
|
||||
)
|
||||
|
||||
if abs(account_bal - stock_bal) > 0.1:
|
||||
precision = get_field_precision(
|
||||
frappe.get_meta("GL Entry").get_field("debit"),
|
||||
currency=frappe.get_cached_value("Company", company, "default_currency"),
|
||||
)
|
||||
|
||||
diff = flt(stock_bal - account_bal, precision)
|
||||
|
||||
error_reason = _(
|
||||
"Stock Value ({0}) and Account Balance ({1}) are out of sync for account {2} and it's linked warehouses as on {3}."
|
||||
).format(stock_bal, account_bal, frappe.bold(account), posting_date)
|
||||
error_resolution = _("Please create an adjustment Journal Entry for amount {0} on {1}").format(
|
||||
frappe.bold(diff), frappe.bold(posting_date)
|
||||
)
|
||||
|
||||
frappe.msgprint(
|
||||
msg="""{0}<br></br>{1}<br></br>""".format(error_reason, error_resolution),
|
||||
raise_exception=StockValueAndAccountBalanceOutOfSync,
|
||||
title=_("Values Out Of Sync"),
|
||||
primary_action={
|
||||
"label": _("Make Journal Entry"),
|
||||
"client_action": "erpnext.route_to_adjustment_jv",
|
||||
"args": get_journal_entry(account, stock_adjustment_account, diff),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_stock_accounts(company, voucher_type=None, voucher_no=None):
|
||||
stock_accounts = [
|
||||
d.name
|
||||
|
@ -65,7 +65,6 @@ class TestRequestforQuotation(FrappeTestCase):
|
||||
)
|
||||
sq.submit()
|
||||
|
||||
frappe.form_dict = frappe.local("form_dict")
|
||||
frappe.form_dict.name = rfq.name
|
||||
|
||||
self.assertEqual(check_supplier_has_docname_access(supplier_wt_appos[0].get("supplier")), True)
|
||||
|
@ -18,16 +18,16 @@
|
||||
"tax_id",
|
||||
"tax_category",
|
||||
"tax_withholding_category",
|
||||
"is_transporter",
|
||||
"is_internal_supplier",
|
||||
"represents_company",
|
||||
"image",
|
||||
"column_break0",
|
||||
"supplier_group",
|
||||
"supplier_type",
|
||||
"allow_purchase_invoice_creation_without_purchase_order",
|
||||
"allow_purchase_invoice_creation_without_purchase_receipt",
|
||||
"is_internal_supplier",
|
||||
"represents_company",
|
||||
"disabled",
|
||||
"is_transporter",
|
||||
"warn_rfqs",
|
||||
"warn_pos",
|
||||
"prevent_rfqs",
|
||||
@ -38,12 +38,6 @@
|
||||
"default_currency",
|
||||
"column_break_10",
|
||||
"default_price_list",
|
||||
"section_credit_limit",
|
||||
"payment_terms",
|
||||
"cb_21",
|
||||
"on_hold",
|
||||
"hold_type",
|
||||
"release_date",
|
||||
"address_contacts",
|
||||
"address_html",
|
||||
"column_break1",
|
||||
@ -57,6 +51,12 @@
|
||||
"primary_address",
|
||||
"default_payable_accounts",
|
||||
"accounts",
|
||||
"section_credit_limit",
|
||||
"payment_terms",
|
||||
"cb_21",
|
||||
"on_hold",
|
||||
"hold_type",
|
||||
"release_date",
|
||||
"default_tax_withholding_config",
|
||||
"column_break2",
|
||||
"website",
|
||||
@ -258,7 +258,7 @@
|
||||
"collapsible": 1,
|
||||
"fieldname": "section_credit_limit",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Credit Limit"
|
||||
"label": "Payment Terms"
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_terms",
|
||||
@ -432,7 +432,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2021-10-20 22:03:33.147249",
|
||||
"modified": "2022-04-16 18:02:27.838623",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
@ -497,6 +497,7 @@
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"title_field": "supplier_name",
|
||||
"track_changes": 1
|
||||
}
|
@ -180,6 +180,7 @@ class AccountsController(TransactionBase):
|
||||
else:
|
||||
self.validate_deferred_start_and_end_date()
|
||||
|
||||
self.validate_deferred_income_expense_account()
|
||||
self.set_inter_company_account()
|
||||
|
||||
if self.doctype == "Purchase Invoice":
|
||||
@ -208,6 +209,27 @@ class AccountsController(TransactionBase):
|
||||
(self.doctype, self.name),
|
||||
)
|
||||
|
||||
def validate_deferred_income_expense_account(self):
|
||||
field_map = {
|
||||
"Sales Invoice": "deferred_revenue_account",
|
||||
"Purchase Invoice": "deferred_expense_account",
|
||||
}
|
||||
|
||||
for item in self.get("items"):
|
||||
if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"):
|
||||
if not item.get(field_map.get(self.doctype)):
|
||||
default_deferred_account = frappe.db.get_value(
|
||||
"Company", self.company, "default_" + field_map.get(self.doctype)
|
||||
)
|
||||
if not default_deferred_account:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Please update deferred revenue/expense account in item row or default account in company master"
|
||||
).format(item.idx)
|
||||
)
|
||||
else:
|
||||
item.set(field_map.get(self.doctype), default_deferred_account)
|
||||
|
||||
def validate_deferred_start_and_end_date(self):
|
||||
for d in self.items:
|
||||
if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"):
|
||||
@ -1975,12 +1997,13 @@ def get_advance_journal_entries(
|
||||
|
||||
reference_condition = " and (" + " or ".join(conditions) + ")" if conditions else ""
|
||||
|
||||
# nosemgrep
|
||||
journal_entries = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
"Journal Entry" as reference_type, t1.name as reference_name,
|
||||
t1.remark as remarks, t2.{0} as amount, t2.name as reference_row,
|
||||
t2.reference_name as against_order
|
||||
t2.reference_name as against_order, t2.exchange_rate
|
||||
from
|
||||
`tabJournal Entry` t1, `tabJournal Entry Account` t2
|
||||
where
|
||||
|
@ -22,6 +22,9 @@ class QtyMismatchError(ValidationError):
|
||||
|
||||
|
||||
class BuyingController(StockController, Subcontracting):
|
||||
def __setup__(self):
|
||||
self.flags.ignore_permlevel_for_fields = ["buying_price_list", "price_list_currency"]
|
||||
|
||||
def get_feed(self):
|
||||
if self.get("supplier_name"):
|
||||
return _("From {0} | {1} {2}").format(self.supplier_name, self.currency, self.grand_total)
|
||||
|
@ -330,7 +330,6 @@ def make_return_doc(doctype, source_name, target_doc=None):
|
||||
doc = frappe.get_doc(target)
|
||||
doc.is_return = 1
|
||||
doc.return_against = source.name
|
||||
doc.ignore_pricing_rule = 1
|
||||
doc.set_warehouse = ""
|
||||
if doctype == "Sales Invoice" or doctype == "POS Invoice":
|
||||
doc.is_pos = source.is_pos
|
||||
|
@ -16,6 +16,9 @@ from erpnext.stock.utils import get_incoming_rate
|
||||
|
||||
|
||||
class SellingController(StockController):
|
||||
def __setup__(self):
|
||||
self.flags.ignore_permlevel_for_fields = ["selling_price_list", "price_list_currency"]
|
||||
|
||||
def get_feed(self):
|
||||
return _("To {0} | {1} {2}").format(self.customer_name, self.currency, self.grand_total)
|
||||
|
||||
|
@ -307,6 +307,11 @@ class calculate_taxes_and_totals(object):
|
||||
self.doc.round_floats_in(self.doc, ["total", "base_total", "net_total", "base_net_total"])
|
||||
|
||||
def calculate_shipping_charges(self):
|
||||
|
||||
# Do not apply shipping rule for POS
|
||||
if self.doc.get("is_pos"):
|
||||
return
|
||||
|
||||
if hasattr(self.doc, "shipping_rule") and self.doc.shipping_rule:
|
||||
shipping_rule = frappe.get_doc("Shipping Rule", self.doc.shipping_rule)
|
||||
shipping_rule.apply(self.doc)
|
||||
|
@ -126,7 +126,8 @@ class Opportunity(TransactionBase):
|
||||
def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None):
|
||||
if not self.has_active_quotation():
|
||||
self.status = "Lost"
|
||||
self.lost_reasons = self.competitors = []
|
||||
self.lost_reasons = []
|
||||
self.competitors = []
|
||||
|
||||
if detailed_reason:
|
||||
self.order_lost_reason = detailed_reason
|
||||
|
@ -24,17 +24,16 @@ frappe.ui.form.on("E Commerce Settings", {
|
||||
);
|
||||
}
|
||||
|
||||
frappe.model.with_doctype("Item", () => {
|
||||
frappe.model.with_doctype("Website Item", () => {
|
||||
const web_item_meta = frappe.get_meta('Website Item');
|
||||
|
||||
const valid_fields = web_item_meta.fields.filter(
|
||||
df => ["Link", "Table MultiSelect"].includes(df.fieldtype) && !df.hidden
|
||||
).map(df => ({ label: df.label, value: df.fieldname }));
|
||||
|
||||
frm.fields_dict.filter_fields.grid.update_docfield_property(
|
||||
'fieldname', 'fieldtype', 'Select'
|
||||
const valid_fields = web_item_meta.fields.filter(df =>
|
||||
["Link", "Table MultiSelect"].includes(df.fieldtype) && !df.hidden
|
||||
).map(df =>
|
||||
({ label: df.label, value: df.fieldname })
|
||||
);
|
||||
frm.fields_dict.filter_fields.grid.update_docfield_property(
|
||||
|
||||
frm.get_field("filter_fields").grid.update_docfield_property(
|
||||
'fieldname', 'options', valid_fields
|
||||
);
|
||||
});
|
||||
|
@ -27,7 +27,7 @@ class ECommerceSettings(Document):
|
||||
self.is_redisearch_loaded = is_search_module_loaded()
|
||||
|
||||
def validate(self):
|
||||
self.validate_field_filters()
|
||||
self.validate_field_filters(self.filter_fields, self.enable_field_filters)
|
||||
self.validate_attribute_filters()
|
||||
self.validate_checkout()
|
||||
self.validate_search_index_fields()
|
||||
@ -51,21 +51,22 @@ class ECommerceSettings(Document):
|
||||
define_autocomplete_dictionary()
|
||||
create_website_items_index()
|
||||
|
||||
def validate_field_filters(self):
|
||||
if not (self.enable_field_filters and self.filter_fields):
|
||||
@staticmethod
|
||||
def validate_field_filters(filter_fields, enable_field_filters):
|
||||
if not (enable_field_filters and filter_fields):
|
||||
return
|
||||
|
||||
item_meta = frappe.get_meta("Item")
|
||||
web_item_meta = frappe.get_meta("Website Item")
|
||||
valid_fields = [
|
||||
df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]
|
||||
df.fieldname for df in web_item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]
|
||||
]
|
||||
|
||||
for f in self.filter_fields:
|
||||
if f.fieldname not in valid_fields:
|
||||
for row in filter_fields:
|
||||
if row.fieldname not in valid_fields:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Filter Fields Row #{0}: Fieldname <b>{1}</b> must be of type 'Link' or 'Table MultiSelect'"
|
||||
).format(f.idx, f.fieldname)
|
||||
"Filter Fields Row #{0}: Fieldname {1} must be of type 'Link' or 'Table MultiSelect'"
|
||||
).format(row.idx, frappe.bold(row.fieldname))
|
||||
)
|
||||
|
||||
def validate_attribute_filters(self):
|
||||
|
@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import unittest
|
||||
|
||||
@ -11,44 +10,35 @@ from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
|
||||
|
||||
|
||||
class TestECommerceSettings(unittest.TestCase):
|
||||
def setUp(self):
|
||||
frappe.db.sql("""delete from `tabSingles` where doctype="Shipping Cart Settings" """)
|
||||
|
||||
def get_cart_settings(self):
|
||||
return frappe.get_doc({"doctype": "E Commerce Settings", "company": "_Test Company"})
|
||||
|
||||
# NOTE: Exchangrate API has all enabled currencies that ERPNext supports.
|
||||
# We aren't checking just currency exchange record anymore
|
||||
# while validating price list currency exchange rate to that of company.
|
||||
# The API is being used to fetch the rate which again almost always
|
||||
# gives back a valid value (for valid currencies).
|
||||
# This makes the test obsolete.
|
||||
# Commenting because im not sure if there's a better test we can write
|
||||
|
||||
# def test_exchange_rate_exists(self):
|
||||
# frappe.db.sql("""delete from `tabCurrency Exchange`""")
|
||||
|
||||
# cart_settings = self.get_cart_settings()
|
||||
# cart_settings.price_list = "_Test Price List Rest of the World"
|
||||
# self.assertRaises(ShoppingCartSetupError, cart_settings.validate_exchange_rates_exist)
|
||||
|
||||
# from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
|
||||
# test_records as currency_exchange_records,
|
||||
# )
|
||||
# frappe.get_doc(currency_exchange_records[0]).insert()
|
||||
# cart_settings.validate_exchange_rates_exist()
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_tax_rule_validation(self):
|
||||
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0")
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
cart_settings = self.get_cart_settings()
|
||||
cart_settings = frappe.get_doc("E Commerce Settings")
|
||||
cart_settings.enabled = 1
|
||||
if not frappe.db.get_value("Tax Rule", {"use_for_shopping_cart": 1}, "name"):
|
||||
self.assertRaises(ShoppingCartSetupError, cart_settings.validate_tax_rule)
|
||||
|
||||
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 1")
|
||||
|
||||
def test_invalid_filter_fields(self):
|
||||
"Check if Item fields are blocked in E Commerce Settings filter fields."
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
|
||||
|
||||
setup_e_commerce_settings({"enable_field_filters": 1})
|
||||
|
||||
create_custom_field(
|
||||
"Item",
|
||||
dict(owner="Administrator", fieldname="test_data", label="Test", fieldtype="Data"),
|
||||
)
|
||||
settings = frappe.get_doc("E Commerce Settings")
|
||||
settings.append("filter_fields", {"fieldname": "test_data"})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, settings.save)
|
||||
|
||||
|
||||
def setup_e_commerce_settings(values_dict):
|
||||
"Accepts a dict of values that updates E Commerce Settings."
|
||||
|
@ -22,12 +22,14 @@ class ProductFiltersBuilder:
|
||||
fields, filter_data = [], []
|
||||
filter_fields = [row.fieldname for row in self.doc.filter_fields] # fields in settings
|
||||
|
||||
# filter valid field filters i.e. those that exist in Item
|
||||
item_meta = frappe.get_meta("Item", cached=True)
|
||||
fields = [item_meta.get_field(field) for field in filter_fields if item_meta.has_field(field)]
|
||||
# filter valid field filters i.e. those that exist in Website Item
|
||||
web_item_meta = frappe.get_meta("Website Item", cached=True)
|
||||
fields = [
|
||||
web_item_meta.get_field(field) for field in filter_fields if web_item_meta.has_field(field)
|
||||
]
|
||||
|
||||
for df in fields:
|
||||
item_filters, item_or_filters = {"published_in_website": 1}, []
|
||||
item_filters, item_or_filters = {"published": 1}, []
|
||||
link_doctype_values = self.get_filtered_link_doctype_records(df)
|
||||
|
||||
if df.fieldtype == "Link":
|
||||
@ -50,9 +52,13 @@ class ProductFiltersBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
# exclude variants if mentioned in settings
|
||||
if frappe.db.get_single_value("E Commerce Settings", "hide_variants"):
|
||||
item_filters["variant_of"] = ["is", "not set"]
|
||||
|
||||
# Get link field values attached to published items
|
||||
item_values = frappe.get_all(
|
||||
"Item",
|
||||
"Website Item",
|
||||
fields=[df.fieldname],
|
||||
filters=item_filters,
|
||||
or_filters=item_or_filters,
|
||||
|
@ -277,6 +277,54 @@ class TestProductDataEngine(unittest.TestCase):
|
||||
# tear down
|
||||
setup_e_commerce_settings({"enable_attribute_filters": 1, "hide_variants": 0})
|
||||
|
||||
def test_custom_field_as_filter(self):
|
||||
"Test if custom field functions as filter correctly."
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
|
||||
|
||||
create_custom_field(
|
||||
"Website Item",
|
||||
dict(
|
||||
owner="Administrator",
|
||||
fieldname="supplier",
|
||||
label="Supplier",
|
||||
fieldtype="Link",
|
||||
options="Supplier",
|
||||
insert_after="on_backorder",
|
||||
),
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Website Item", {"item_code": "Test 11I Laptop"}, "supplier", "_Test Supplier"
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Website Item", {"item_code": "Test 12I Laptop"}, "supplier", "_Test Supplier 1"
|
||||
)
|
||||
|
||||
settings = frappe.get_doc("E Commerce Settings")
|
||||
settings.append("filter_fields", {"fieldname": "supplier"})
|
||||
settings.save()
|
||||
|
||||
filter_engine = ProductFiltersBuilder()
|
||||
field_filters = filter_engine.get_field_filters()
|
||||
custom_filter = field_filters[1]
|
||||
filter_values = custom_filter[1]
|
||||
|
||||
self.assertEqual(custom_filter[0].options, "Supplier")
|
||||
self.assertEqual(len(filter_values), 2)
|
||||
self.assertIn("_Test Supplier", filter_values)
|
||||
|
||||
# test if custom filter works in query
|
||||
field_filters = {"supplier": "_Test Supplier 1"}
|
||||
engine = ProductQuery()
|
||||
result = engine.query(
|
||||
attributes={}, fields=field_filters, search_term=None, start=0, item_group=None
|
||||
)
|
||||
items = result.get("items")
|
||||
|
||||
# check if only 'Raw Material' are fetched in the right order
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0].get("item_code"), "Test 12I Laptop")
|
||||
|
||||
|
||||
def create_variant_web_item():
|
||||
"Create Variant and Template Website Items."
|
||||
|
@ -37,11 +37,26 @@ def handle_end_call(**kwargs):
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def handle_missed_call(**kwargs):
|
||||
update_call_log(kwargs, "Missed")
|
||||
status = ""
|
||||
call_type = kwargs.get("CallType")
|
||||
dial_call_status = kwargs.get("DialCallStatus")
|
||||
|
||||
if call_type == "incomplete" and dial_call_status == "no-answer":
|
||||
status = "No Answer"
|
||||
elif call_type == "client-hangup" and dial_call_status == "canceled":
|
||||
status = "Canceled"
|
||||
elif call_type == "incomplete" and dial_call_status == "failed":
|
||||
status = "Failed"
|
||||
|
||||
update_call_log(kwargs, status)
|
||||
|
||||
|
||||
def update_call_log(call_payload, status="Ringing", call_log=None):
|
||||
call_log = call_log or get_call_log(call_payload)
|
||||
|
||||
# for a new sid, call_log and get_call_log will be empty so create a new log
|
||||
if not call_log:
|
||||
call_log = create_call_log(call_payload)
|
||||
if call_log:
|
||||
call_log.status = status
|
||||
call_log.to = call_payload.get("DialWhomNumber")
|
||||
@ -53,16 +68,9 @@ def update_call_log(call_payload, status="Ringing", call_log=None):
|
||||
|
||||
|
||||
def get_call_log(call_payload):
|
||||
call_log = frappe.get_all(
|
||||
"Call Log",
|
||||
{
|
||||
"id": call_payload.get("CallSid"),
|
||||
},
|
||||
limit=1,
|
||||
)
|
||||
|
||||
if call_log:
|
||||
return frappe.get_doc("Call Log", call_log[0].name)
|
||||
call_log_id = call_payload.get("CallSid")
|
||||
if frappe.db.exists("Call Log", call_log_id):
|
||||
return frappe.get_doc("Call Log", call_log_id)
|
||||
|
||||
|
||||
def create_call_log(call_payload):
|
||||
|
@ -59,6 +59,7 @@ treeviews = [
|
||||
"Warehouse",
|
||||
"Item Group",
|
||||
"Customer Group",
|
||||
"Supplier Group",
|
||||
"Sales Person",
|
||||
"Territory",
|
||||
"Assessment Group",
|
||||
|
@ -5,11 +5,21 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, cstr, formatdate, get_datetime, getdate, nowdate
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.utils import cint, cstr, formatdate, get_datetime, get_link_to_form, getdate, nowdate
|
||||
|
||||
from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings
|
||||
from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee
|
||||
|
||||
|
||||
class DuplicateAttendanceError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class OverlappingShiftAttendanceError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class Attendance(Document):
|
||||
def validate(self):
|
||||
from erpnext.controllers.status_updater import validate_status
|
||||
@ -18,6 +28,7 @@ class Attendance(Document):
|
||||
validate_active_employee(self.employee)
|
||||
self.validate_attendance_date()
|
||||
self.validate_duplicate_record()
|
||||
self.validate_overlapping_shift_attendance()
|
||||
self.validate_employee_status()
|
||||
self.check_leave_record()
|
||||
|
||||
@ -35,21 +46,35 @@ class Attendance(Document):
|
||||
frappe.throw(_("Attendance date can not be less than employee's joining date"))
|
||||
|
||||
def validate_duplicate_record(self):
|
||||
res = frappe.db.sql(
|
||||
"""
|
||||
select name from `tabAttendance`
|
||||
where employee = %s
|
||||
and attendance_date = %s
|
||||
and name != %s
|
||||
and docstatus != 2
|
||||
""",
|
||||
(self.employee, getdate(self.attendance_date), self.name),
|
||||
duplicate = get_duplicate_attendance_record(
|
||||
self.employee, self.attendance_date, self.shift, self.name
|
||||
)
|
||||
if res:
|
||||
|
||||
if duplicate:
|
||||
frappe.throw(
|
||||
_("Attendance for employee {0} is already marked for the date {1}").format(
|
||||
frappe.bold(self.employee), frappe.bold(self.attendance_date)
|
||||
)
|
||||
_("Attendance for employee {0} is already marked for the date {1}: {2}").format(
|
||||
frappe.bold(self.employee),
|
||||
frappe.bold(self.attendance_date),
|
||||
get_link_to_form("Attendance", duplicate[0].name),
|
||||
),
|
||||
title=_("Duplicate Attendance"),
|
||||
exc=DuplicateAttendanceError,
|
||||
)
|
||||
|
||||
def validate_overlapping_shift_attendance(self):
|
||||
attendance = get_overlapping_shift_attendance(
|
||||
self.employee, self.attendance_date, self.shift, self.name
|
||||
)
|
||||
|
||||
if attendance:
|
||||
frappe.throw(
|
||||
_("Attendance for employee {0} is already marked for an overlapping shift {1}: {2}").format(
|
||||
frappe.bold(self.employee),
|
||||
frappe.bold(attendance.shift),
|
||||
get_link_to_form("Attendance", attendance.name),
|
||||
),
|
||||
title=_("Overlapping Shift Attendance"),
|
||||
exc=OverlappingShiftAttendanceError,
|
||||
)
|
||||
|
||||
def validate_employee_status(self):
|
||||
@ -103,6 +128,69 @@ class Attendance(Document):
|
||||
frappe.throw(_("Employee {0} is not active or does not exist").format(self.employee))
|
||||
|
||||
|
||||
def get_duplicate_attendance_record(employee, attendance_date, shift, name=None):
|
||||
attendance = frappe.qb.DocType("Attendance")
|
||||
query = (
|
||||
frappe.qb.from_(attendance)
|
||||
.select(attendance.name)
|
||||
.where((attendance.employee == employee) & (attendance.docstatus < 2))
|
||||
)
|
||||
|
||||
if shift:
|
||||
query = query.where(
|
||||
Criterion.any(
|
||||
[
|
||||
Criterion.all(
|
||||
[
|
||||
((attendance.shift.isnull()) | (attendance.shift == "")),
|
||||
(attendance.attendance_date == attendance_date),
|
||||
]
|
||||
),
|
||||
Criterion.all(
|
||||
[
|
||||
((attendance.shift.isnotnull()) | (attendance.shift != "")),
|
||||
(attendance.attendance_date == attendance_date),
|
||||
(attendance.shift == shift),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where((attendance.attendance_date == attendance_date))
|
||||
|
||||
if name:
|
||||
query = query.where(attendance.name != name)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_overlapping_shift_attendance(employee, attendance_date, shift, name=None):
|
||||
if not shift:
|
||||
return {}
|
||||
|
||||
attendance = frappe.qb.DocType("Attendance")
|
||||
query = (
|
||||
frappe.qb.from_(attendance)
|
||||
.select(attendance.name, attendance.shift)
|
||||
.where(
|
||||
(attendance.employee == employee)
|
||||
& (attendance.docstatus < 2)
|
||||
& (attendance.attendance_date == attendance_date)
|
||||
& (attendance.shift != shift)
|
||||
)
|
||||
)
|
||||
|
||||
if name:
|
||||
query = query.where(attendance.name != name)
|
||||
|
||||
overlapping_attendance = query.run(as_dict=True)
|
||||
|
||||
if overlapping_attendance and has_overlapping_timings(shift, overlapping_attendance[0].shift):
|
||||
return overlapping_attendance[0]
|
||||
return {}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_events(start, end, filters=None):
|
||||
events = []
|
||||
@ -141,28 +229,39 @@ def add_attendance(events, start, end, conditions=None):
|
||||
|
||||
|
||||
def mark_attendance(
|
||||
employee, attendance_date, status, shift=None, leave_type=None, ignore_validate=False
|
||||
employee,
|
||||
attendance_date,
|
||||
status,
|
||||
shift=None,
|
||||
leave_type=None,
|
||||
ignore_validate=False,
|
||||
late_entry=False,
|
||||
early_exit=False,
|
||||
):
|
||||
if not frappe.db.exists(
|
||||
"Attendance",
|
||||
{"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")},
|
||||
):
|
||||
company = frappe.db.get_value("Employee", employee, "company")
|
||||
attendance = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendance",
|
||||
"employee": employee,
|
||||
"attendance_date": attendance_date,
|
||||
"status": status,
|
||||
"company": company,
|
||||
"shift": shift,
|
||||
"leave_type": leave_type,
|
||||
}
|
||||
)
|
||||
attendance.flags.ignore_validate = ignore_validate
|
||||
attendance.insert()
|
||||
attendance.submit()
|
||||
return attendance.name
|
||||
if get_duplicate_attendance_record(employee, attendance_date, shift):
|
||||
return
|
||||
|
||||
if get_overlapping_shift_attendance(employee, attendance_date, shift):
|
||||
return
|
||||
|
||||
company = frappe.db.get_value("Employee", employee, "company")
|
||||
attendance = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendance",
|
||||
"employee": employee,
|
||||
"attendance_date": attendance_date,
|
||||
"status": status,
|
||||
"company": company,
|
||||
"shift": shift,
|
||||
"leave_type": leave_type,
|
||||
"late_entry": late_entry,
|
||||
"early_exit": early_exit,
|
||||
}
|
||||
)
|
||||
attendance.flags.ignore_validate = ignore_validate
|
||||
attendance.insert()
|
||||
attendance.submit()
|
||||
return attendance.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
@ -6,6 +6,8 @@ from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, get_year_ending, get_year_start, getdate, now_datetime, nowdate
|
||||
|
||||
from erpnext.hr.doctype.attendance.attendance import (
|
||||
DuplicateAttendanceError,
|
||||
OverlappingShiftAttendanceError,
|
||||
get_month_map,
|
||||
get_unmarked_days,
|
||||
mark_attendance,
|
||||
@ -23,11 +25,112 @@ class TestAttendance(FrappeTestCase):
|
||||
from_date = get_year_start(getdate())
|
||||
to_date = get_year_ending(getdate())
|
||||
self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
|
||||
frappe.db.delete("Attendance")
|
||||
|
||||
def test_duplicate_attendance(self):
|
||||
employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
|
||||
date = nowdate()
|
||||
|
||||
mark_attendance(employee, date, "Present")
|
||||
attendance = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendance",
|
||||
"employee": employee,
|
||||
"attendance_date": date,
|
||||
"status": "Absent",
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(DuplicateAttendanceError, attendance.insert)
|
||||
|
||||
def test_duplicate_attendance_with_shift(self):
|
||||
from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
|
||||
|
||||
employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
|
||||
date = nowdate()
|
||||
|
||||
shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
|
||||
mark_attendance(employee, date, "Present", shift=shift_1.name)
|
||||
|
||||
# attendance record with shift
|
||||
attendance = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendance",
|
||||
"employee": employee,
|
||||
"attendance_date": date,
|
||||
"status": "Absent",
|
||||
"company": "_Test Company",
|
||||
"shift": shift_1.name,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(DuplicateAttendanceError, attendance.insert)
|
||||
|
||||
# attendance record without any shift
|
||||
attendance = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendance",
|
||||
"employee": employee,
|
||||
"attendance_date": date,
|
||||
"status": "Absent",
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(DuplicateAttendanceError, attendance.insert)
|
||||
|
||||
def test_overlapping_shift_attendance_validation(self):
|
||||
from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
|
||||
|
||||
employee = make_employee("test_overlap_attendance@example.com", company="_Test Company")
|
||||
date = nowdate()
|
||||
|
||||
shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
|
||||
shift_2 = setup_shift_type(shift_type="Shift 2", start_time="09:30:00", end_time="11:00:00")
|
||||
|
||||
mark_attendance(employee, date, "Present", shift=shift_1.name)
|
||||
|
||||
# attendance record with overlapping shift
|
||||
attendance = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendance",
|
||||
"employee": employee,
|
||||
"attendance_date": date,
|
||||
"status": "Absent",
|
||||
"company": "_Test Company",
|
||||
"shift": shift_2.name,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(OverlappingShiftAttendanceError, attendance.insert)
|
||||
|
||||
def test_allow_attendance_with_different_shifts(self):
|
||||
# allows attendance with 2 different non-overlapping shifts
|
||||
from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
|
||||
|
||||
employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
|
||||
date = nowdate()
|
||||
|
||||
shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
|
||||
shift_2 = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="12:00:00")
|
||||
|
||||
mark_attendance(employee, date, "Present", shift_1.name)
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendance",
|
||||
"employee": employee,
|
||||
"attendance_date": date,
|
||||
"status": "Absent",
|
||||
"company": "_Test Company",
|
||||
"shift": shift_2.name,
|
||||
}
|
||||
).insert()
|
||||
|
||||
def test_mark_absent(self):
|
||||
employee = make_employee("test_mark_absent@example.com")
|
||||
date = nowdate()
|
||||
frappe.db.delete("Attendance", {"employee": employee, "attendance_date": date})
|
||||
|
||||
attendance = mark_attendance(employee, date, "Absent")
|
||||
fetch_attendance = frappe.get_value(
|
||||
"Attendance", {"employee": employee, "attendance_date": date, "status": "Absent"}
|
||||
@ -42,7 +145,6 @@ class TestAttendance(FrappeTestCase):
|
||||
employee = make_employee(
|
||||
"test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
|
||||
)
|
||||
frappe.db.delete("Attendance", {"employee": employee})
|
||||
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
|
||||
|
||||
first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
|
||||
@ -67,8 +169,6 @@ class TestAttendance(FrappeTestCase):
|
||||
employee = make_employee(
|
||||
"test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
|
||||
)
|
||||
frappe.db.delete("Attendance", {"employee": employee})
|
||||
|
||||
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
|
||||
|
||||
first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
|
||||
@ -95,7 +195,6 @@ class TestAttendance(FrappeTestCase):
|
||||
employee = make_employee(
|
||||
"test_unmarked_days_as_per_doj@example.com", date_of_joining=doj, relieving_date=relieving_date
|
||||
)
|
||||
frappe.db.delete("Attendance", {"employee": employee})
|
||||
|
||||
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
|
||||
|
||||
|
@ -4,7 +4,7 @@
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2013-03-07 09:04:18",
|
||||
"creation": "2022-02-21 11:54:09.632218",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"editable_grid": 1,
|
||||
@ -872,6 +872,7 @@
|
||||
],
|
||||
"search_fields": "employee_name",
|
||||
"show_name_in_global_search": 1,
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
@ -7,6 +7,10 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, get_datetime
|
||||
|
||||
from erpnext.hr.doctype.attendance.attendance import (
|
||||
get_duplicate_attendance_record,
|
||||
get_overlapping_shift_attendance,
|
||||
)
|
||||
from erpnext.hr.doctype.shift_assignment.shift_assignment import (
|
||||
get_actual_start_end_datetime_of_shift,
|
||||
)
|
||||
@ -33,24 +37,24 @@ class EmployeeCheckin(Document):
|
||||
shift_actual_timings = get_actual_start_end_datetime_of_shift(
|
||||
self.employee, get_datetime(self.time), True
|
||||
)
|
||||
if shift_actual_timings[0] and shift_actual_timings[1]:
|
||||
if shift_actual_timings:
|
||||
if (
|
||||
shift_actual_timings[2].shift_type.determine_check_in_and_check_out
|
||||
shift_actual_timings.shift_type.determine_check_in_and_check_out
|
||||
== "Strictly based on Log Type in Employee Checkin"
|
||||
and not self.log_type
|
||||
and not self.skip_auto_attendance
|
||||
):
|
||||
frappe.throw(
|
||||
_("Log Type is required for check-ins falling in the shift: {0}.").format(
|
||||
shift_actual_timings[2].shift_type.name
|
||||
shift_actual_timings.shift_type.name
|
||||
)
|
||||
)
|
||||
if not self.attendance:
|
||||
self.shift = shift_actual_timings[2].shift_type.name
|
||||
self.shift_actual_start = shift_actual_timings[0]
|
||||
self.shift_actual_end = shift_actual_timings[1]
|
||||
self.shift_start = shift_actual_timings[2].start_datetime
|
||||
self.shift_end = shift_actual_timings[2].end_datetime
|
||||
self.shift = shift_actual_timings.shift_type.name
|
||||
self.shift_actual_start = shift_actual_timings.actual_start
|
||||
self.shift_actual_end = shift_actual_timings.actual_end
|
||||
self.shift_start = shift_actual_timings.start_datetime
|
||||
self.shift_end = shift_actual_timings.end_datetime
|
||||
else:
|
||||
self.shift = None
|
||||
|
||||
@ -136,10 +140,10 @@ def mark_attendance_and_link_log(
|
||||
return None
|
||||
elif attendance_status in ("Present", "Absent", "Half Day"):
|
||||
employee_doc = frappe.get_doc("Employee", employee)
|
||||
if not frappe.db.exists(
|
||||
"Attendance",
|
||||
{"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")},
|
||||
):
|
||||
duplicate = get_duplicate_attendance_record(employee, attendance_date, shift)
|
||||
overlapping = get_overlapping_shift_attendance(employee, attendance_date, shift)
|
||||
|
||||
if not duplicate and not overlapping:
|
||||
doc_dict = {
|
||||
"doctype": "Attendance",
|
||||
"employee": employee,
|
||||
@ -232,7 +236,7 @@ def calculate_working_hours(logs, check_in_out_type, working_hours_calc_type):
|
||||
|
||||
|
||||
def time_diff_in_hours(start, end):
|
||||
return round((end - start).total_seconds() / 3600, 1)
|
||||
return round(float((end - start).total_seconds()) / 3600, 2)
|
||||
|
||||
|
||||
def find_index_in_dict(dict_list, key, value):
|
||||
|
@ -2,10 +2,19 @@
|
||||
# See license.txt
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.utils import now_datetime, nowdate
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
get_time,
|
||||
get_year_ending,
|
||||
get_year_start,
|
||||
getdate,
|
||||
now_datetime,
|
||||
nowdate,
|
||||
)
|
||||
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.employee_checkin.employee_checkin import (
|
||||
@ -13,9 +22,22 @@ from erpnext.hr.doctype.employee_checkin.employee_checkin import (
|
||||
calculate_working_hours,
|
||||
mark_attendance_and_link_log,
|
||||
)
|
||||
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
|
||||
from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
|
||||
from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type
|
||||
from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
|
||||
|
||||
|
||||
class TestEmployeeCheckin(unittest.TestCase):
|
||||
class TestEmployeeCheckin(FrappeTestCase):
|
||||
def setUp(self):
|
||||
frappe.db.delete("Shift Type")
|
||||
frappe.db.delete("Shift Assignment")
|
||||
frappe.db.delete("Employee Checkin")
|
||||
|
||||
from_date = get_year_start(getdate())
|
||||
to_date = get_year_ending(getdate())
|
||||
self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
|
||||
|
||||
def test_add_log_based_on_employee_field(self):
|
||||
employee = make_employee("test_add_log_based_on_employee_field@example.com")
|
||||
employee = frappe.get_doc("Employee", employee)
|
||||
@ -103,6 +125,188 @@ class TestEmployeeCheckin(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(working_hours, (4.5, logs_type_2[1].time, logs_type_2[-1].time))
|
||||
|
||||
def test_fetch_shift(self):
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
|
||||
# shift setup for 8-12
|
||||
shift_type = setup_shift_type()
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
# within shift time
|
||||
timestamp = datetime.combine(date, get_time("08:45:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift_type.name)
|
||||
|
||||
# "begin checkin before shift time" = 60 mins, so should work for 7:00:00
|
||||
timestamp = datetime.combine(date, get_time("07:00:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift_type.name)
|
||||
|
||||
# "allow checkout after shift end time" = 60 mins, so should work for 13:00:00
|
||||
timestamp = datetime.combine(date, get_time("13:00:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift_type.name)
|
||||
|
||||
# should not fetch this shift beyond allowed time
|
||||
timestamp = datetime.combine(date, get_time("13:01:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertIsNone(log.shift)
|
||||
|
||||
def test_fetch_shift_for_assignment_with_end_date(self):
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
|
||||
# shift setup for 8-12
|
||||
shift1 = setup_shift_type()
|
||||
# 12:30 - 16:30
|
||||
shift2 = setup_shift_type(shift_type="Shift 2", start_time="12:30:00", end_time="16:30:00")
|
||||
|
||||
date = getdate()
|
||||
make_shift_assignment(shift1.name, employee, date, add_days(date, 15))
|
||||
make_shift_assignment(shift2.name, employee, date, add_days(date, 15))
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:45:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift1.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("12:45:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift2.name)
|
||||
|
||||
# log after end date
|
||||
timestamp = datetime.combine(add_days(date, 16), get_time("12:45:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertIsNone(log.shift)
|
||||
|
||||
def test_shift_start_and_end_timings(self):
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
|
||||
# shift setup for 8-12
|
||||
shift_type = setup_shift_type()
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:45:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
|
||||
self.assertEqual(log.shift, shift_type.name)
|
||||
self.assertEqual(log.shift_start, datetime.combine(date, get_time("08:00:00")))
|
||||
self.assertEqual(log.shift_end, datetime.combine(date, get_time("12:00:00")))
|
||||
self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("07:00:00")))
|
||||
self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("13:00:00")))
|
||||
|
||||
def test_fetch_shift_based_on_default_shift(self):
|
||||
employee = make_employee("test_default_shift@example.com", company="_Test Company")
|
||||
default_shift = setup_shift_type(
|
||||
shift_type="Default Shift", start_time="14:00:00", end_time="16:00:00"
|
||||
)
|
||||
|
||||
date = getdate()
|
||||
frappe.db.set_value("Employee", employee, "default_shift", default_shift.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("14:45:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
|
||||
# should consider default shift
|
||||
self.assertEqual(log.shift, default_shift.name)
|
||||
|
||||
def test_fetch_shift_spanning_over_two_days(self):
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
shift_type = setup_shift_type(
|
||||
shift_type="Midnight Shift", start_time="23:00:00", end_time="01:00:00"
|
||||
)
|
||||
date = getdate()
|
||||
next_day = add_days(date, 1)
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
# log falls in the first day
|
||||
timestamp = datetime.combine(date, get_time("23:00:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
|
||||
self.assertEqual(log.shift, shift_type.name)
|
||||
self.assertEqual(log.shift_start, datetime.combine(date, get_time("23:00:00")))
|
||||
self.assertEqual(log.shift_end, datetime.combine(next_day, get_time("01:00:00")))
|
||||
self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("22:00:00")))
|
||||
self.assertEqual(log.shift_actual_end, datetime.combine(next_day, get_time("02:00:00")))
|
||||
|
||||
log.delete()
|
||||
|
||||
# log falls in the second day
|
||||
prev_day = add_days(date, -1)
|
||||
timestamp = datetime.combine(date, get_time("01:30:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift_type.name)
|
||||
self.assertEqual(log.shift_start, datetime.combine(prev_day, get_time("23:00:00")))
|
||||
self.assertEqual(log.shift_end, datetime.combine(date, get_time("01:00:00")))
|
||||
self.assertEqual(log.shift_actual_start, datetime.combine(prev_day, get_time("22:00:00")))
|
||||
self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("02:00:00")))
|
||||
|
||||
def test_no_shift_fetched_on_holiday_as_per_shift_holiday_list(self):
|
||||
date = getdate()
|
||||
from_date = get_year_start(date)
|
||||
to_date = get_year_ending(date)
|
||||
holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
|
||||
|
||||
employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company")
|
||||
setup_shift_type(shift_type="Test Holiday Shift", holiday_list=holiday_list)
|
||||
|
||||
first_sunday = get_first_sunday(holiday_list, for_date=date)
|
||||
timestamp = datetime.combine(first_sunday, get_time("08:00:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
|
||||
self.assertIsNone(log.shift)
|
||||
|
||||
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
|
||||
def test_no_shift_fetched_on_holiday_as_per_employee_holiday_list(self):
|
||||
employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company")
|
||||
shift_type = setup_shift_type(shift_type="Test Holiday Shift")
|
||||
shift_type.holiday_list = None
|
||||
shift_type.save()
|
||||
|
||||
date = getdate()
|
||||
|
||||
first_sunday = get_first_sunday(self.holiday_list, for_date=date)
|
||||
timestamp = datetime.combine(first_sunday, get_time("08:00:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
|
||||
self.assertIsNone(log.shift)
|
||||
|
||||
def test_consecutive_shift_assignments_overlapping_within_grace_period(self):
|
||||
# test adjustment for start and end times if they are overlapping
|
||||
# within "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time" periods
|
||||
employee = make_employee("test_shift@example.com", company="_Test Company")
|
||||
|
||||
# 8 - 12
|
||||
shift1 = setup_shift_type()
|
||||
# 12:30 - 16:30
|
||||
shift2 = setup_shift_type(
|
||||
shift_type="Consecutive Shift", start_time="12:30:00", end_time="16:30:00"
|
||||
)
|
||||
|
||||
# the actual start and end times (with grace) for these shifts are 7 - 13 and 11:30 - 17:30
|
||||
date = getdate()
|
||||
make_shift_assignment(shift1.name, employee, date)
|
||||
make_shift_assignment(shift2.name, employee, date)
|
||||
|
||||
# log at 12:30 should set shift2 and actual start as 12 and not 11:30
|
||||
timestamp = datetime.combine(date, get_time("12:30:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift2.name)
|
||||
self.assertEqual(log.shift_start, datetime.combine(date, get_time("12:30:00")))
|
||||
self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("12:00:00")))
|
||||
|
||||
# log at 12:00 should set shift1 and actual end as 12 and not 1 since the next shift's grace starts
|
||||
timestamp = datetime.combine(date, get_time("12:00:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift1.name)
|
||||
self.assertEqual(log.shift_end, datetime.combine(date, get_time("12:00:00")))
|
||||
self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("12:00:00")))
|
||||
|
||||
# log at 12:01 should set shift2
|
||||
timestamp = datetime.combine(date, get_time("12:01:00"))
|
||||
log = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log.shift, shift2.name)
|
||||
|
||||
|
||||
def make_n_checkins(employee, n, hours_to_reverse=1):
|
||||
logs = [make_checkin(employee, now_datetime() - timedelta(hours=hours_to_reverse, minutes=n + 1))]
|
||||
|
@ -34,6 +34,15 @@ frappe.ui.form.on("Leave Allocation", {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// make new leaves allocated field read only if allocation is created via leave policy assignment
|
||||
// and leave type is earned leave, since these leaves would be allocated via the scheduler
|
||||
if (frm.doc.leave_policy_assignment) {
|
||||
frappe.db.get_value("Leave Type", frm.doc.leave_type, "is_earned_leave", (r) => {
|
||||
if (r && cint(r.is_earned_leave))
|
||||
frm.set_df_property("new_leaves_allocated", "read_only", 1);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
expire_allocation: function(frm) {
|
||||
|
@ -237,7 +237,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-01-18 19:15:53.262536",
|
||||
"modified": "2022-04-07 09:50:33.145825",
|
||||
"modified_by": "Administrator",
|
||||
"module": "HR",
|
||||
"name": "Leave Allocation",
|
||||
@ -281,5 +281,6 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"timeline_field": "employee",
|
||||
"title_field": "employee_name"
|
||||
"title_field": "employee_name",
|
||||
"track_changes": 1
|
||||
}
|
@ -3,88 +3,125 @@
|
||||
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cstr, getdate, now_datetime, nowdate
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.utils import cstr, get_datetime, get_link_to_form, get_time, getdate, now_datetime
|
||||
|
||||
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
|
||||
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
|
||||
from erpnext.hr.utils import validate_active_employee
|
||||
|
||||
|
||||
class OverlappingShiftError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class ShiftAssignment(Document):
|
||||
def validate(self):
|
||||
validate_active_employee(self.employee)
|
||||
self.validate_overlapping_dates()
|
||||
self.validate_overlapping_shifts()
|
||||
|
||||
if self.end_date:
|
||||
self.validate_from_to_dates("start_date", "end_date")
|
||||
|
||||
def validate_overlapping_dates(self):
|
||||
def validate_overlapping_shifts(self):
|
||||
overlapping_dates = self.get_overlapping_dates()
|
||||
if len(overlapping_dates):
|
||||
# if dates are overlapping, check if timings are overlapping, else allow
|
||||
overlapping_timings = has_overlapping_timings(self.shift_type, overlapping_dates[0].shift_type)
|
||||
if overlapping_timings:
|
||||
self.throw_overlap_error(overlapping_dates[0])
|
||||
|
||||
def get_overlapping_dates(self):
|
||||
if not self.name:
|
||||
self.name = "New Shift Assignment"
|
||||
|
||||
condition = """and (
|
||||
end_date is null
|
||||
or
|
||||
%(start_date)s between start_date and end_date
|
||||
"""
|
||||
|
||||
if self.end_date:
|
||||
condition += """ or
|
||||
%(end_date)s between start_date and end_date
|
||||
or
|
||||
start_date between %(start_date)s and %(end_date)s
|
||||
) """
|
||||
else:
|
||||
condition += """ ) """
|
||||
|
||||
assigned_shifts = frappe.db.sql(
|
||||
"""
|
||||
select name, shift_type, start_date ,end_date, docstatus, status
|
||||
from `tabShift Assignment`
|
||||
where
|
||||
employee=%(employee)s and docstatus = 1
|
||||
and name != %(name)s
|
||||
and status = "Active"
|
||||
{0}
|
||||
""".format(
|
||||
condition
|
||||
),
|
||||
{
|
||||
"employee": self.employee,
|
||||
"shift_type": self.shift_type,
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
"name": self.name,
|
||||
},
|
||||
as_dict=1,
|
||||
shift = frappe.qb.DocType("Shift Assignment")
|
||||
query = (
|
||||
frappe.qb.from_(shift)
|
||||
.select(shift.name, shift.shift_type, shift.docstatus, shift.status)
|
||||
.where(
|
||||
(shift.employee == self.employee)
|
||||
& (shift.docstatus == 1)
|
||||
& (shift.name != self.name)
|
||||
& (shift.status == "Active")
|
||||
)
|
||||
)
|
||||
|
||||
if len(assigned_shifts):
|
||||
self.throw_overlap_error(assigned_shifts[0])
|
||||
if self.end_date:
|
||||
query = query.where(
|
||||
Criterion.any(
|
||||
[
|
||||
Criterion.any(
|
||||
[
|
||||
shift.end_date.isnull(),
|
||||
((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date)),
|
||||
]
|
||||
),
|
||||
Criterion.any(
|
||||
[
|
||||
((self.end_date >= shift.start_date) & (self.end_date <= shift.end_date)),
|
||||
shift.start_date.between(self.start_date, self.end_date),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
shift.end_date.isnull()
|
||||
| ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date))
|
||||
)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def throw_overlap_error(self, shift_details):
|
||||
shift_details = frappe._dict(shift_details)
|
||||
if shift_details.docstatus == 1 and shift_details.status == "Active":
|
||||
msg = _("Employee {0} already has Active Shift {1}: {2}").format(
|
||||
frappe.bold(self.employee), frappe.bold(self.shift_type), frappe.bold(shift_details.name)
|
||||
msg = _(
|
||||
"Employee {0} already has an active Shift {1}: {2} that overlaps within this period."
|
||||
).format(
|
||||
frappe.bold(self.employee),
|
||||
frappe.bold(shift_details.shift_type),
|
||||
get_link_to_form("Shift Assignment", shift_details.name),
|
||||
)
|
||||
if shift_details.start_date:
|
||||
msg += " " + _("from {0}").format(getdate(self.start_date).strftime("%d-%m-%Y"))
|
||||
title = "Ongoing Shift"
|
||||
if shift_details.end_date:
|
||||
msg += " " + _("to {0}").format(getdate(self.end_date).strftime("%d-%m-%Y"))
|
||||
title = "Active Shift"
|
||||
if msg:
|
||||
frappe.throw(msg, title=title)
|
||||
frappe.throw(msg, title=_("Overlapping Shifts"), exc=OverlappingShiftError)
|
||||
|
||||
|
||||
def has_overlapping_timings(shift_1: str, shift_2: str) -> bool:
|
||||
"""
|
||||
Accepts two shift types and checks whether their timings are overlapping
|
||||
"""
|
||||
curr_shift = frappe.db.get_value("Shift Type", shift_1, ["start_time", "end_time"], as_dict=True)
|
||||
overlapping_shift = frappe.db.get_value(
|
||||
"Shift Type", shift_2, ["start_time", "end_time"], as_dict=True
|
||||
)
|
||||
|
||||
if (
|
||||
(
|
||||
curr_shift.start_time > overlapping_shift.start_time
|
||||
and curr_shift.start_time < overlapping_shift.end_time
|
||||
)
|
||||
or (
|
||||
curr_shift.end_time > overlapping_shift.start_time
|
||||
and curr_shift.end_time < overlapping_shift.end_time
|
||||
)
|
||||
or (
|
||||
curr_shift.start_time <= overlapping_shift.start_time
|
||||
and curr_shift.end_time >= overlapping_shift.end_time
|
||||
)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_events(start, end, filters=None):
|
||||
events = []
|
||||
from frappe.desk.calendar import get_event_conditions
|
||||
|
||||
employee = frappe.db.get_value(
|
||||
"Employee", {"user_id": frappe.session.user}, ["name", "company"], as_dict=True
|
||||
@ -95,20 +132,22 @@ def get_events(start, end, filters=None):
|
||||
employee = ""
|
||||
company = frappe.db.get_value("Global Defaults", None, "default_company")
|
||||
|
||||
from frappe.desk.reportview import get_filters_cond
|
||||
|
||||
conditions = get_filters_cond("Shift Assignment", filters, [])
|
||||
add_assignments(events, start, end, conditions=conditions)
|
||||
conditions = get_event_conditions("Shift Assignment", filters)
|
||||
events = add_assignments(start, end, conditions=conditions)
|
||||
return events
|
||||
|
||||
|
||||
def add_assignments(events, start, end, conditions=None):
|
||||
def add_assignments(start, end, conditions=None):
|
||||
events = []
|
||||
|
||||
query = """select name, start_date, end_date, employee_name,
|
||||
employee, docstatus, shift_type
|
||||
from `tabShift Assignment` where
|
||||
start_date >= %(start_date)s
|
||||
or end_date <= %(end_date)s
|
||||
or (%(start_date)s between start_date and end_date and %(end_date)s between start_date and end_date)
|
||||
(
|
||||
start_date >= %(start_date)s
|
||||
or end_date <= %(end_date)s
|
||||
or (%(start_date)s between start_date and end_date and %(end_date)s between start_date and end_date)
|
||||
)
|
||||
and docstatus = 1"""
|
||||
if conditions:
|
||||
query += conditions
|
||||
@ -155,102 +194,195 @@ def get_shift_type_timing(shift_types):
|
||||
return shift_timing_map
|
||||
|
||||
|
||||
def get_shift_for_time(shifts: List[Dict], for_timestamp: datetime) -> Dict:
|
||||
"""Returns shift with details for given timestamp"""
|
||||
valid_shifts = []
|
||||
|
||||
for entry in shifts:
|
||||
shift_details = get_shift_details(entry.shift_type, for_timestamp=for_timestamp)
|
||||
|
||||
if (
|
||||
get_datetime(shift_details.actual_start)
|
||||
<= get_datetime(for_timestamp)
|
||||
<= get_datetime(shift_details.actual_end)
|
||||
):
|
||||
valid_shifts.append(shift_details)
|
||||
|
||||
valid_shifts.sort(key=lambda x: x["actual_start"])
|
||||
|
||||
if len(valid_shifts) > 1:
|
||||
for i in range(len(valid_shifts) - 1):
|
||||
# comparing 2 consecutive shifts and adjusting start and end times
|
||||
# if they are overlapping within grace period
|
||||
curr_shift = valid_shifts[i]
|
||||
next_shift = valid_shifts[i + 1]
|
||||
|
||||
if curr_shift and next_shift:
|
||||
next_shift.actual_start = (
|
||||
curr_shift.end_datetime
|
||||
if next_shift.actual_start < curr_shift.end_datetime
|
||||
else next_shift.actual_start
|
||||
)
|
||||
curr_shift.actual_end = (
|
||||
next_shift.actual_start
|
||||
if curr_shift.actual_end > next_shift.actual_start
|
||||
else curr_shift.actual_end
|
||||
)
|
||||
|
||||
valid_shifts[i] = curr_shift
|
||||
valid_shifts[i + 1] = next_shift
|
||||
|
||||
return get_exact_shift(valid_shifts, for_timestamp) or {}
|
||||
|
||||
return (valid_shifts and valid_shifts[0]) or {}
|
||||
|
||||
|
||||
def get_shifts_for_date(employee: str, for_timestamp: datetime) -> List[Dict[str, str]]:
|
||||
"""Returns list of shifts with details for given date"""
|
||||
assignment = frappe.qb.DocType("Shift Assignment")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(assignment)
|
||||
.select(assignment.name, assignment.shift_type)
|
||||
.where(
|
||||
(assignment.employee == employee)
|
||||
& (assignment.docstatus == 1)
|
||||
& (assignment.status == "Active")
|
||||
& (assignment.start_date <= getdate(for_timestamp.date()))
|
||||
& (
|
||||
Criterion.any(
|
||||
[
|
||||
assignment.end_date.isnull(),
|
||||
(assignment.end_date.isnotnull() & (getdate(for_timestamp.date()) <= assignment.end_date)),
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_shift_for_timestamp(employee: str, for_timestamp: datetime) -> Dict:
|
||||
shifts = get_shifts_for_date(employee, for_timestamp)
|
||||
if shifts:
|
||||
return get_shift_for_time(shifts, for_timestamp)
|
||||
return {}
|
||||
|
||||
|
||||
def get_employee_shift(
|
||||
employee, for_date=None, consider_default_shift=False, next_shift_direction=None
|
||||
):
|
||||
employee: str,
|
||||
for_timestamp: datetime = None,
|
||||
consider_default_shift: bool = False,
|
||||
next_shift_direction: str = None,
|
||||
) -> Dict:
|
||||
"""Returns a Shift Type for the given employee on the given date. (excluding the holidays)
|
||||
|
||||
:param employee: Employee for which shift is required.
|
||||
:param for_date: Date on which shift are required
|
||||
:param for_timestamp: DateTime on which shift is required
|
||||
:param consider_default_shift: If set to true, default shift is taken when no shift assignment is found.
|
||||
:param next_shift_direction: One of: None, 'forward', 'reverse'. Direction to look for next shift if shift not found on given date.
|
||||
"""
|
||||
if for_date is None:
|
||||
for_date = nowdate()
|
||||
if for_timestamp is None:
|
||||
for_timestamp = now_datetime()
|
||||
|
||||
shift_details = get_shift_for_timestamp(employee, for_timestamp)
|
||||
|
||||
# if shift assignment is not found, consider default shift
|
||||
default_shift = frappe.db.get_value("Employee", employee, "default_shift")
|
||||
shift_type_name = None
|
||||
shift_assignment_details = frappe.db.get_value(
|
||||
"Shift Assignment",
|
||||
{"employee": employee, "start_date": ("<=", for_date), "docstatus": "1", "status": "Active"},
|
||||
["shift_type", "end_date"],
|
||||
if not shift_details and consider_default_shift:
|
||||
shift_details = get_shift_details(default_shift, for_timestamp)
|
||||
|
||||
# if its a holiday, reset
|
||||
if shift_details and is_holiday_date(employee, shift_details):
|
||||
shift_details = None
|
||||
|
||||
# if no shift is found, find next or prev shift assignment based on direction
|
||||
if not shift_details and next_shift_direction:
|
||||
shift_details = get_prev_or_next_shift(
|
||||
employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction
|
||||
)
|
||||
|
||||
return shift_details or {}
|
||||
|
||||
|
||||
def get_prev_or_next_shift(
|
||||
employee: str,
|
||||
for_timestamp: datetime,
|
||||
consider_default_shift: bool,
|
||||
default_shift: str,
|
||||
next_shift_direction: str,
|
||||
) -> Dict:
|
||||
"""Returns a dict of shift details for the next or prev shift based on the next_shift_direction"""
|
||||
MAX_DAYS = 366
|
||||
shift_details = {}
|
||||
|
||||
if consider_default_shift and default_shift:
|
||||
direction = -1 if next_shift_direction == "reverse" else 1
|
||||
for i in range(MAX_DAYS):
|
||||
date = for_timestamp + timedelta(days=direction * (i + 1))
|
||||
shift_details = get_employee_shift(employee, date, consider_default_shift, None)
|
||||
if shift_details:
|
||||
break
|
||||
else:
|
||||
direction = "<" if next_shift_direction == "reverse" else ">"
|
||||
sort_order = "desc" if next_shift_direction == "reverse" else "asc"
|
||||
dates = frappe.db.get_all(
|
||||
"Shift Assignment",
|
||||
["start_date", "end_date"],
|
||||
{
|
||||
"employee": employee,
|
||||
"start_date": (direction, for_timestamp.date()),
|
||||
"docstatus": 1,
|
||||
"status": "Active",
|
||||
},
|
||||
as_list=True,
|
||||
limit=MAX_DAYS,
|
||||
order_by="start_date " + sort_order,
|
||||
)
|
||||
|
||||
if dates:
|
||||
for date in dates:
|
||||
if date[1] and date[1] < for_timestamp.date():
|
||||
continue
|
||||
shift_details = get_employee_shift(
|
||||
employee, datetime.combine(date[0], for_timestamp.time()), consider_default_shift, None
|
||||
)
|
||||
if shift_details:
|
||||
break
|
||||
|
||||
return shift_details or {}
|
||||
|
||||
|
||||
def is_holiday_date(employee: str, shift_details: Dict) -> bool:
|
||||
holiday_list_name = frappe.db.get_value(
|
||||
"Shift Type", shift_details.shift_type.name, "holiday_list"
|
||||
)
|
||||
|
||||
if shift_assignment_details:
|
||||
shift_type_name = shift_assignment_details[0]
|
||||
if not holiday_list_name:
|
||||
holiday_list_name = get_holiday_list_for_employee(employee, False)
|
||||
|
||||
# if end_date present means that shift is over after end_date else it is a ongoing shift.
|
||||
if shift_assignment_details[1] and for_date >= shift_assignment_details[1]:
|
||||
shift_type_name = None
|
||||
|
||||
if not shift_type_name and consider_default_shift:
|
||||
shift_type_name = default_shift
|
||||
if shift_type_name:
|
||||
holiday_list_name = frappe.db.get_value("Shift Type", shift_type_name, "holiday_list")
|
||||
if not holiday_list_name:
|
||||
holiday_list_name = get_holiday_list_for_employee(employee, False)
|
||||
if holiday_list_name and is_holiday(holiday_list_name, for_date):
|
||||
shift_type_name = None
|
||||
|
||||
if not shift_type_name and next_shift_direction:
|
||||
MAX_DAYS = 366
|
||||
if consider_default_shift and default_shift:
|
||||
direction = -1 if next_shift_direction == "reverse" else +1
|
||||
for i in range(MAX_DAYS):
|
||||
date = for_date + timedelta(days=direction * (i + 1))
|
||||
shift_details = get_employee_shift(employee, date, consider_default_shift, None)
|
||||
if shift_details:
|
||||
shift_type_name = shift_details.shift_type.name
|
||||
for_date = date
|
||||
break
|
||||
else:
|
||||
direction = "<" if next_shift_direction == "reverse" else ">"
|
||||
sort_order = "desc" if next_shift_direction == "reverse" else "asc"
|
||||
dates = frappe.db.get_all(
|
||||
"Shift Assignment",
|
||||
["start_date", "end_date"],
|
||||
{
|
||||
"employee": employee,
|
||||
"start_date": (direction, for_date),
|
||||
"docstatus": "1",
|
||||
"status": "Active",
|
||||
},
|
||||
as_list=True,
|
||||
limit=MAX_DAYS,
|
||||
order_by="start_date " + sort_order,
|
||||
)
|
||||
|
||||
if dates:
|
||||
for date in dates:
|
||||
if date[1] and date[1] < for_date:
|
||||
continue
|
||||
shift_details = get_employee_shift(employee, date[0], consider_default_shift, None)
|
||||
if shift_details:
|
||||
shift_type_name = shift_details.shift_type.name
|
||||
for_date = date[0]
|
||||
break
|
||||
|
||||
return get_shift_details(shift_type_name, for_date)
|
||||
return holiday_list_name and is_holiday(holiday_list_name, shift_details.start_datetime.date())
|
||||
|
||||
|
||||
def get_employee_shift_timings(employee, for_timestamp=None, consider_default_shift=False):
|
||||
def get_employee_shift_timings(
|
||||
employee: str, for_timestamp: datetime = None, consider_default_shift: bool = False
|
||||
) -> List[Dict]:
|
||||
"""Returns previous shift, current/upcoming shift, next_shift for the given timestamp and employee"""
|
||||
if for_timestamp is None:
|
||||
for_timestamp = now_datetime()
|
||||
|
||||
# write and verify a test case for midnight shift.
|
||||
prev_shift = curr_shift = next_shift = None
|
||||
curr_shift = get_employee_shift(employee, for_timestamp.date(), consider_default_shift, "forward")
|
||||
curr_shift = get_employee_shift(employee, for_timestamp, consider_default_shift, "forward")
|
||||
if curr_shift:
|
||||
next_shift = get_employee_shift(
|
||||
employee,
|
||||
curr_shift.start_datetime.date() + timedelta(days=1),
|
||||
consider_default_shift,
|
||||
"forward",
|
||||
employee, curr_shift.start_datetime + timedelta(days=1), consider_default_shift, "forward"
|
||||
)
|
||||
prev_shift = get_employee_shift(
|
||||
employee, for_timestamp.date() + timedelta(days=-1), consider_default_shift, "reverse"
|
||||
employee, for_timestamp + timedelta(days=-1), consider_default_shift, "reverse"
|
||||
)
|
||||
|
||||
if curr_shift:
|
||||
# adjust actual start and end times if they are overlapping with grace period (before start and after end)
|
||||
if prev_shift:
|
||||
curr_shift.actual_start = (
|
||||
prev_shift.end_datetime
|
||||
@ -273,31 +405,102 @@ def get_employee_shift_timings(employee, for_timestamp=None, consider_default_sh
|
||||
if curr_shift.actual_end > next_shift.actual_start
|
||||
else curr_shift.actual_end
|
||||
)
|
||||
|
||||
return prev_shift, curr_shift, next_shift
|
||||
|
||||
|
||||
def get_shift_details(shift_type_name, for_date=None):
|
||||
"""Returns Shift Details which contain some additional information as described below.
|
||||
'shift_details' contains the following keys:
|
||||
'shift_type' - Object of DocType Shift Type,
|
||||
'start_datetime' - Date and Time of shift start on given date,
|
||||
'end_datetime' - Date and Time of shift end on given date,
|
||||
'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time',
|
||||
'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time'(None is returned if this is zero)
|
||||
def get_actual_start_end_datetime_of_shift(
|
||||
employee: str, for_timestamp: datetime, consider_default_shift: bool = False
|
||||
) -> Dict:
|
||||
"""Returns a Dict containing shift details with actual_start and actual_end datetime values
|
||||
Here 'actual' means taking into account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time".
|
||||
Empty Dict is returned if the timestamp is outside any actual shift timings.
|
||||
|
||||
:param shift_type_name: shift type name for which shift_details is required.
|
||||
:param for_date: Date on which shift_details are required
|
||||
:param employee (str): Employee name
|
||||
:param for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime
|
||||
:param consider_default_shift (bool, optional): Flag (defaults to False) to specify whether to consider
|
||||
default shift in employee master if no shift assignment is found
|
||||
"""
|
||||
shift_timings_as_per_timestamp = get_employee_shift_timings(
|
||||
employee, for_timestamp, consider_default_shift
|
||||
)
|
||||
return get_exact_shift(shift_timings_as_per_timestamp, for_timestamp)
|
||||
|
||||
|
||||
def get_exact_shift(shifts: List, for_timestamp: datetime) -> Dict:
|
||||
"""Returns the shift details (dict) for the exact shift in which the 'for_timestamp' value falls among multiple shifts"""
|
||||
shift_details = dict()
|
||||
timestamp_list = []
|
||||
|
||||
for shift in shifts:
|
||||
if shift:
|
||||
timestamp_list.extend([shift.actual_start, shift.actual_end])
|
||||
else:
|
||||
timestamp_list.extend([None, None])
|
||||
|
||||
timestamp_index = None
|
||||
for index, timestamp in enumerate(timestamp_list):
|
||||
if not timestamp:
|
||||
continue
|
||||
|
||||
if for_timestamp < timestamp:
|
||||
timestamp_index = index
|
||||
elif for_timestamp == timestamp:
|
||||
# on timestamp boundary
|
||||
if index % 2 == 1:
|
||||
timestamp_index = index
|
||||
else:
|
||||
timestamp_index = index + 1
|
||||
|
||||
if timestamp_index:
|
||||
break
|
||||
|
||||
if timestamp_index and timestamp_index % 2 == 1:
|
||||
shift_details = shifts[int((timestamp_index - 1) / 2)]
|
||||
|
||||
return shift_details
|
||||
|
||||
|
||||
def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> Dict:
|
||||
"""Returns a Dict containing shift details with the following data:
|
||||
'shift_type' - Object of DocType Shift Type,
|
||||
'start_datetime' - datetime of shift start on given timestamp,
|
||||
'end_datetime' - datetime of shift end on given timestamp,
|
||||
'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time',
|
||||
'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time' (None is returned if this is zero)
|
||||
|
||||
:param shift_type_name (str): shift type name for which shift_details are required.
|
||||
:param for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime
|
||||
"""
|
||||
if not shift_type_name:
|
||||
return None
|
||||
if not for_date:
|
||||
for_date = nowdate()
|
||||
return {}
|
||||
|
||||
if for_timestamp is None:
|
||||
for_timestamp = now_datetime()
|
||||
|
||||
shift_type = frappe.get_doc("Shift Type", shift_type_name)
|
||||
start_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.start_time
|
||||
for_date = (
|
||||
for_date + timedelta(days=1) if shift_type.start_time > shift_type.end_time else for_date
|
||||
shift_actual_start = shift_type.start_time - timedelta(
|
||||
minutes=shift_type.begin_check_in_before_shift_start_time
|
||||
)
|
||||
end_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.end_time
|
||||
|
||||
if shift_type.start_time > shift_type.end_time:
|
||||
# shift spans accross 2 different days
|
||||
if get_time(for_timestamp.time()) >= get_time(shift_actual_start):
|
||||
# if for_timestamp is greater than start time, it's within the first day
|
||||
start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
|
||||
for_timestamp = for_timestamp + timedelta(days=1)
|
||||
end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
|
||||
|
||||
elif get_time(for_timestamp.time()) < get_time(shift_actual_start):
|
||||
# if for_timestamp is less than start time, it's within the second day
|
||||
end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
|
||||
for_timestamp = for_timestamp + timedelta(days=-1)
|
||||
start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
|
||||
else:
|
||||
# start and end timings fall on the same day
|
||||
start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
|
||||
end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
|
||||
|
||||
actual_start = start_datetime - timedelta(
|
||||
minutes=shift_type.begin_check_in_before_shift_start_time
|
||||
)
|
||||
@ -312,34 +515,3 @@ def get_shift_details(shift_type_name, for_date=None):
|
||||
"actual_end": actual_end,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_actual_start_end_datetime_of_shift(employee, for_datetime, consider_default_shift=False):
|
||||
"""Takes a datetime and returns the 'actual' start datetime and end datetime of the shift in which the timestamp belongs.
|
||||
Here 'actual' means - taking in to account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time".
|
||||
None is returned if the timestamp is outside any actual shift timings.
|
||||
Shift Details is also returned(current/upcoming i.e. if timestamp not in any actual shift then details of next shift returned)
|
||||
"""
|
||||
actual_shift_start = actual_shift_end = shift_details = None
|
||||
shift_timings_as_per_timestamp = get_employee_shift_timings(
|
||||
employee, for_datetime, consider_default_shift
|
||||
)
|
||||
timestamp_list = []
|
||||
for shift in shift_timings_as_per_timestamp:
|
||||
if shift:
|
||||
timestamp_list.extend([shift.actual_start, shift.actual_end])
|
||||
else:
|
||||
timestamp_list.extend([None, None])
|
||||
timestamp_index = None
|
||||
for index, timestamp in enumerate(timestamp_list):
|
||||
if timestamp and for_datetime <= timestamp:
|
||||
timestamp_index = index
|
||||
break
|
||||
if timestamp_index and timestamp_index % 2 == 1:
|
||||
shift_details = shift_timings_as_per_timestamp[int((timestamp_index - 1) / 2)]
|
||||
actual_shift_start = shift_details.actual_start
|
||||
actual_shift_end = shift_details.actual_end
|
||||
elif timestamp_index:
|
||||
shift_details = shift_timings_as_per_timestamp[int(timestamp_index / 2)]
|
||||
|
||||
return actual_shift_start, actual_shift_end, shift_details
|
||||
|
@ -4,16 +4,23 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, nowdate
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, getdate, nowdate
|
||||
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.shift_assignment.shift_assignment import OverlappingShiftError, get_events
|
||||
from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type
|
||||
|
||||
test_dependencies = ["Shift Type"]
|
||||
|
||||
|
||||
class TestShiftAssignment(unittest.TestCase):
|
||||
class TestShiftAssignment(FrappeTestCase):
|
||||
def setUp(self):
|
||||
frappe.db.sql("delete from `tabShift Assignment`")
|
||||
frappe.db.delete("Shift Assignment")
|
||||
frappe.db.delete("Shift Type")
|
||||
|
||||
def test_make_shift_assignment(self):
|
||||
setup_shift_type(shift_type="Day Shift")
|
||||
shift_assignment = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Assignment",
|
||||
@ -29,7 +36,7 @@ class TestShiftAssignment(unittest.TestCase):
|
||||
|
||||
def test_overlapping_for_ongoing_shift(self):
|
||||
# shift should be Ongoing if Only start_date is present and status = Active
|
||||
|
||||
setup_shift_type(shift_type="Day Shift")
|
||||
shift_assignment_1 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Assignment",
|
||||
@ -54,11 +61,11 @@ class TestShiftAssignment(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, shift_assignment.save)
|
||||
self.assertRaises(OverlappingShiftError, shift_assignment.save)
|
||||
|
||||
def test_overlapping_for_fixed_period_shift(self):
|
||||
# shift should is for Fixed period if Only start_date and end_date both are present and status = Active
|
||||
|
||||
setup_shift_type(shift_type="Day Shift")
|
||||
shift_assignment_1 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Assignment",
|
||||
@ -85,4 +92,80 @@ class TestShiftAssignment(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, shift_assignment_3.save)
|
||||
self.assertRaises(OverlappingShiftError, shift_assignment_3.save)
|
||||
|
||||
def test_overlapping_for_a_fixed_period_shift_and_ongoing_shift(self):
|
||||
employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
|
||||
|
||||
# shift setup for 8-12
|
||||
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
|
||||
date = getdate()
|
||||
# shift with end date
|
||||
make_shift_assignment(shift_type.name, employee, date, add_days(date, 30))
|
||||
|
||||
# shift setup for 11-15
|
||||
shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
|
||||
date = getdate()
|
||||
|
||||
# shift assignment without end date
|
||||
shift2 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Assignment",
|
||||
"shift_type": shift_type.name,
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"start_date": date,
|
||||
}
|
||||
)
|
||||
self.assertRaises(OverlappingShiftError, shift2.insert)
|
||||
|
||||
def test_overlap_validation_for_shifts_on_same_day_with_overlapping_timeslots(self):
|
||||
employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
|
||||
|
||||
# shift setup for 8-12
|
||||
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
# shift setup for 11-15
|
||||
shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
|
||||
date = getdate()
|
||||
|
||||
shift2 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Assignment",
|
||||
"shift_type": shift_type.name,
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"start_date": date,
|
||||
}
|
||||
)
|
||||
self.assertRaises(OverlappingShiftError, shift2.insert)
|
||||
|
||||
def test_multiple_shift_assignments_for_same_day(self):
|
||||
employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
|
||||
|
||||
# shift setup for 8-12
|
||||
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
# shift setup for 13-15
|
||||
shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00")
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
def test_shift_assignment_calendar(self):
|
||||
employee1 = make_employee("test_shift_assignment1@example.com", company="_Test Company")
|
||||
employee2 = make_employee("test_shift_assignment2@example.com", company="_Test Company")
|
||||
|
||||
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
|
||||
date = getdate()
|
||||
shift1 = make_shift_assignment(shift_type.name, employee1, date)
|
||||
make_shift_assignment(shift_type.name, employee2, date)
|
||||
|
||||
events = get_events(
|
||||
start=date, end=date, filters=[["Shift Assignment", "employee", "=", employee1, False]]
|
||||
)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0]["name"], shift1.name)
|
||||
|
@ -5,12 +5,14 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import formatdate, getdate
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.utils import get_link_to_form, getdate
|
||||
|
||||
from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings
|
||||
from erpnext.hr.utils import share_doc_with_approver, validate_active_employee
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
class OverlappingShiftRequestError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
@ -18,7 +20,7 @@ class ShiftRequest(Document):
|
||||
def validate(self):
|
||||
validate_active_employee(self.employee)
|
||||
self.validate_dates()
|
||||
self.validate_shift_request_overlap_dates()
|
||||
self.validate_overlapping_shift_requests()
|
||||
self.validate_approver()
|
||||
self.validate_default_shift()
|
||||
|
||||
@ -79,37 +81,60 @@ class ShiftRequest(Document):
|
||||
if self.from_date and self.to_date and (getdate(self.to_date) < getdate(self.from_date)):
|
||||
frappe.throw(_("To date cannot be before from date"))
|
||||
|
||||
def validate_shift_request_overlap_dates(self):
|
||||
def validate_overlapping_shift_requests(self):
|
||||
overlapping_dates = self.get_overlapping_dates()
|
||||
if len(overlapping_dates):
|
||||
# if dates are overlapping, check if timings are overlapping, else allow
|
||||
overlapping_timings = has_overlapping_timings(self.shift_type, overlapping_dates[0].shift_type)
|
||||
if overlapping_timings:
|
||||
self.throw_overlap_error(overlapping_dates[0])
|
||||
|
||||
def get_overlapping_dates(self):
|
||||
if not self.name:
|
||||
self.name = "New Shift Request"
|
||||
|
||||
d = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
name, shift_type, from_date, to_date
|
||||
from `tabShift Request`
|
||||
where employee = %(employee)s and docstatus < 2
|
||||
and ((%(from_date)s >= from_date
|
||||
and %(from_date)s <= to_date) or
|
||||
( %(to_date)s >= from_date
|
||||
and %(to_date)s <= to_date ))
|
||||
and name != %(name)s""",
|
||||
{
|
||||
"employee": self.employee,
|
||||
"shift_type": self.shift_type,
|
||||
"from_date": self.from_date,
|
||||
"to_date": self.to_date,
|
||||
"name": self.name,
|
||||
},
|
||||
as_dict=1,
|
||||
shift = frappe.qb.DocType("Shift Request")
|
||||
query = (
|
||||
frappe.qb.from_(shift)
|
||||
.select(shift.name, shift.shift_type)
|
||||
.where((shift.employee == self.employee) & (shift.docstatus < 2) & (shift.name != self.name))
|
||||
)
|
||||
|
||||
for date_overlap in d:
|
||||
if date_overlap["name"]:
|
||||
self.throw_overlap_error(date_overlap)
|
||||
if self.to_date:
|
||||
query = query.where(
|
||||
Criterion.any(
|
||||
[
|
||||
Criterion.any(
|
||||
[
|
||||
shift.to_date.isnull(),
|
||||
((self.from_date >= shift.from_date) & (self.from_date <= shift.to_date)),
|
||||
]
|
||||
),
|
||||
Criterion.any(
|
||||
[
|
||||
((self.to_date >= shift.from_date) & (self.to_date <= shift.to_date)),
|
||||
shift.from_date.between(self.from_date, self.to_date),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
shift.to_date.isnull()
|
||||
| ((self.from_date >= shift.from_date) & (self.from_date <= shift.to_date))
|
||||
)
|
||||
|
||||
def throw_overlap_error(self, d):
|
||||
msg = _("Employee {0} has already applied for {1} between {2} and {3}").format(
|
||||
self.employee, d["shift_type"], formatdate(d["from_date"]), formatdate(d["to_date"])
|
||||
) + """ : <b><a href="/app/Form/Shift Request/{0}">{0}</a></b>""".format(d["name"])
|
||||
frappe.throw(msg, OverlapError)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def throw_overlap_error(self, shift_details):
|
||||
shift_details = frappe._dict(shift_details)
|
||||
msg = _(
|
||||
"Employee {0} has already applied for Shift {1}: {2} that overlaps within this period"
|
||||
).format(
|
||||
frappe.bold(self.employee),
|
||||
frappe.bold(shift_details.shift_type),
|
||||
get_link_to_form("Shift Request", shift_details.name),
|
||||
)
|
||||
|
||||
frappe.throw(msg, title=_("Overlapping Shift Requests"), exc=OverlappingShiftRequestError)
|
||||
|
@ -4,23 +4,24 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.shift_request.shift_request import OverlappingShiftRequestError
|
||||
from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
|
||||
|
||||
test_dependencies = ["Shift Type"]
|
||||
|
||||
|
||||
class TestShiftRequest(unittest.TestCase):
|
||||
class TestShiftRequest(FrappeTestCase):
|
||||
def setUp(self):
|
||||
for doctype in ["Shift Request", "Shift Assignment"]:
|
||||
frappe.db.sql("delete from `tab{doctype}`".format(doctype=doctype))
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
for doctype in ["Shift Request", "Shift Assignment", "Shift Type"]:
|
||||
frappe.db.delete(doctype)
|
||||
|
||||
def test_make_shift_request(self):
|
||||
"Test creation/updation of Shift Assignment from Shift Request."
|
||||
setup_shift_type(shift_type="Day Shift")
|
||||
department = frappe.get_value("Employee", "_T-Employee-00001", "department")
|
||||
set_shift_approver(department)
|
||||
approver = frappe.db.sql(
|
||||
@ -48,6 +49,7 @@ class TestShiftRequest(unittest.TestCase):
|
||||
self.assertEqual(shift_assignment_docstatus, 2)
|
||||
|
||||
def test_shift_request_approver_perms(self):
|
||||
setup_shift_type(shift_type="Day Shift")
|
||||
employee = frappe.get_doc("Employee", "_T-Employee-00001")
|
||||
user = "test_approver_perm_emp@example.com"
|
||||
make_employee(user, "_Test Company")
|
||||
@ -87,6 +89,145 @@ class TestShiftRequest(unittest.TestCase):
|
||||
employee.shift_request_approver = ""
|
||||
employee.save()
|
||||
|
||||
def test_overlap_for_request_without_to_date(self):
|
||||
# shift should be Ongoing if Only from_date is present
|
||||
user = "test_shift_request@example.com"
|
||||
employee = make_employee(user, company="_Test Company", shift_request_approver=user)
|
||||
setup_shift_type(shift_type="Day Shift")
|
||||
|
||||
shift_request = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Request",
|
||||
"shift_type": "Day Shift",
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"from_date": nowdate(),
|
||||
"approver": user,
|
||||
"status": "Approved",
|
||||
}
|
||||
).submit()
|
||||
|
||||
shift_request = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Request",
|
||||
"shift_type": "Day Shift",
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"from_date": add_days(nowdate(), 2),
|
||||
"approver": user,
|
||||
"status": "Approved",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(OverlappingShiftRequestError, shift_request.save)
|
||||
|
||||
def test_overlap_for_request_with_from_and_to_dates(self):
|
||||
user = "test_shift_request@example.com"
|
||||
employee = make_employee(user, company="_Test Company", shift_request_approver=user)
|
||||
setup_shift_type(shift_type="Day Shift")
|
||||
|
||||
shift_request = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Request",
|
||||
"shift_type": "Day Shift",
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"from_date": nowdate(),
|
||||
"to_date": add_days(nowdate(), 30),
|
||||
"approver": user,
|
||||
"status": "Approved",
|
||||
}
|
||||
).submit()
|
||||
|
||||
shift_request = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Request",
|
||||
"shift_type": "Day Shift",
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"from_date": add_days(nowdate(), 10),
|
||||
"to_date": add_days(nowdate(), 35),
|
||||
"approver": user,
|
||||
"status": "Approved",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(OverlappingShiftRequestError, shift_request.save)
|
||||
|
||||
def test_overlapping_for_a_fixed_period_shift_and_ongoing_shift(self):
|
||||
user = "test_shift_request@example.com"
|
||||
employee = make_employee(user, company="_Test Company", shift_request_approver=user)
|
||||
|
||||
# shift setup for 8-12
|
||||
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
|
||||
date = nowdate()
|
||||
|
||||
# shift with end date
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Request",
|
||||
"shift_type": shift_type.name,
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"from_date": date,
|
||||
"to_date": add_days(date, 30),
|
||||
"approver": user,
|
||||
"status": "Approved",
|
||||
}
|
||||
).submit()
|
||||
|
||||
# shift setup for 11-15
|
||||
shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
|
||||
shift2 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Request",
|
||||
"shift_type": shift_type.name,
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"from_date": date,
|
||||
"approver": user,
|
||||
"status": "Approved",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(OverlappingShiftRequestError, shift2.insert)
|
||||
|
||||
def test_allow_non_overlapping_shift_requests_for_same_day(self):
|
||||
user = "test_shift_request@example.com"
|
||||
employee = make_employee(user, company="_Test Company", shift_request_approver=user)
|
||||
|
||||
# shift setup for 8-12
|
||||
shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
|
||||
date = nowdate()
|
||||
|
||||
# shift with end date
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Request",
|
||||
"shift_type": shift_type.name,
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"from_date": date,
|
||||
"to_date": add_days(date, 30),
|
||||
"approver": user,
|
||||
"status": "Approved",
|
||||
}
|
||||
).submit()
|
||||
|
||||
# shift setup for 13-15
|
||||
shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00")
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Request",
|
||||
"shift_type": shift_type.name,
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"from_date": date,
|
||||
"approver": user,
|
||||
"status": "Approved",
|
||||
}
|
||||
).submit()
|
||||
|
||||
|
||||
def set_shift_approver(department):
|
||||
department_doc = frappe.get_doc("Department", department)
|
||||
|
@ -3,21 +3,23 @@
|
||||
|
||||
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, get_datetime, getdate
|
||||
from frappe.utils import cint, get_datetime, get_time, getdate
|
||||
|
||||
from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import daterange
|
||||
from erpnext.hr.doctype.attendance.attendance import mark_attendance
|
||||
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
|
||||
from erpnext.hr.doctype.employee_checkin.employee_checkin import (
|
||||
calculate_working_hours,
|
||||
mark_attendance_and_link_log,
|
||||
)
|
||||
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
|
||||
from erpnext.hr.doctype.shift_assignment.shift_assignment import (
|
||||
get_actual_start_end_datetime_of_shift,
|
||||
get_employee_shift,
|
||||
get_shift_details,
|
||||
)
|
||||
|
||||
|
||||
@ -30,8 +32,9 @@ class ShiftType(Document):
|
||||
or not self.last_sync_of_checkin
|
||||
):
|
||||
return
|
||||
|
||||
filters = {
|
||||
"skip_auto_attendance": "0",
|
||||
"skip_auto_attendance": 0,
|
||||
"attendance": ("is", "not set"),
|
||||
"time": (">=", self.process_attendance_after),
|
||||
"shift_actual_end": ("<", self.last_sync_of_checkin),
|
||||
@ -40,6 +43,7 @@ class ShiftType(Document):
|
||||
logs = frappe.db.get_list(
|
||||
"Employee Checkin", fields="*", filters=filters, order_by="employee,time"
|
||||
)
|
||||
|
||||
for key, group in itertools.groupby(
|
||||
logs, key=lambda x: (x["employee"], x["shift_actual_start"])
|
||||
):
|
||||
@ -52,6 +56,7 @@ class ShiftType(Document):
|
||||
in_time,
|
||||
out_time,
|
||||
) = self.get_attendance(single_shift_logs)
|
||||
|
||||
mark_attendance_and_link_log(
|
||||
single_shift_logs,
|
||||
attendance_status,
|
||||
@ -63,15 +68,16 @@ class ShiftType(Document):
|
||||
out_time,
|
||||
self.name,
|
||||
)
|
||||
|
||||
for employee in self.get_assigned_employee(self.process_attendance_after, True):
|
||||
self.mark_absent_for_dates_with_no_attendance(employee)
|
||||
|
||||
def get_attendance(self, logs):
|
||||
"""Return attendance_status, working_hours, late_entry, early_exit, in_time, out_time
|
||||
for a set of logs belonging to a single shift.
|
||||
Assumtion:
|
||||
1. These logs belongs to an single shift, single employee and is not in a holiday date.
|
||||
2. Logs are in chronological order
|
||||
Assumptions:
|
||||
1. These logs belongs to a single shift, single employee and it's not in a holiday date.
|
||||
2. Logs are in chronological order
|
||||
"""
|
||||
late_entry = early_exit = False
|
||||
total_working_hours, in_time, out_time = calculate_working_hours(
|
||||
@ -91,39 +97,68 @@ class ShiftType(Document):
|
||||
):
|
||||
early_exit = True
|
||||
|
||||
if (
|
||||
self.working_hours_threshold_for_absent
|
||||
and total_working_hours < self.working_hours_threshold_for_absent
|
||||
):
|
||||
return "Absent", total_working_hours, late_entry, early_exit, in_time, out_time
|
||||
if (
|
||||
self.working_hours_threshold_for_half_day
|
||||
and total_working_hours < self.working_hours_threshold_for_half_day
|
||||
):
|
||||
return "Half Day", total_working_hours, late_entry, early_exit, in_time, out_time
|
||||
if (
|
||||
self.working_hours_threshold_for_absent
|
||||
and total_working_hours < self.working_hours_threshold_for_absent
|
||||
):
|
||||
return "Absent", total_working_hours, late_entry, early_exit, in_time, out_time
|
||||
return "Present", total_working_hours, late_entry, early_exit, in_time, out_time
|
||||
|
||||
def mark_absent_for_dates_with_no_attendance(self, employee):
|
||||
"""Marks Absents for the given employee on working days in this shift which have no attendance marked.
|
||||
The Absent is marked starting from 'process_attendance_after' or employee creation date.
|
||||
"""
|
||||
start_date, end_date = self.get_start_and_end_dates(employee)
|
||||
|
||||
# no shift assignment found, no need to process absent attendance records
|
||||
if start_date is None:
|
||||
return
|
||||
|
||||
holiday_list_name = self.holiday_list
|
||||
if not holiday_list_name:
|
||||
holiday_list_name = get_holiday_list_for_employee(employee, False)
|
||||
|
||||
start_time = get_time(self.start_time)
|
||||
|
||||
for date in daterange(getdate(start_date), getdate(end_date)):
|
||||
if is_holiday(holiday_list_name, date):
|
||||
# skip marking absent on a holiday
|
||||
continue
|
||||
|
||||
timestamp = datetime.combine(date, start_time)
|
||||
shift_details = get_employee_shift(employee, timestamp, True)
|
||||
|
||||
if shift_details and shift_details.shift_type.name == self.name:
|
||||
mark_attendance(employee, date, "Absent", self.name)
|
||||
|
||||
def get_start_and_end_dates(self, employee):
|
||||
"""Returns start and end dates for checking attendance and marking absent
|
||||
return: start date = max of `process_attendance_after` and DOJ
|
||||
return: end date = min of shift before `last_sync_of_checkin` and Relieving Date
|
||||
"""
|
||||
date_of_joining, relieving_date, employee_creation = frappe.db.get_value(
|
||||
"Employee", employee, ["date_of_joining", "relieving_date", "creation"]
|
||||
)
|
||||
|
||||
if not date_of_joining:
|
||||
date_of_joining = employee_creation.date()
|
||||
|
||||
start_date = max(getdate(self.process_attendance_after), date_of_joining)
|
||||
actual_shift_datetime = get_actual_start_end_datetime_of_shift(
|
||||
employee, get_datetime(self.last_sync_of_checkin), True
|
||||
)
|
||||
end_date = None
|
||||
|
||||
shift_details = get_shift_details(self.name, get_datetime(self.last_sync_of_checkin))
|
||||
last_shift_time = (
|
||||
actual_shift_datetime[0]
|
||||
if actual_shift_datetime[0]
|
||||
else get_datetime(self.last_sync_of_checkin)
|
||||
)
|
||||
prev_shift = get_employee_shift(
|
||||
employee, last_shift_time.date() - timedelta(days=1), True, "reverse"
|
||||
shift_details.actual_start if shift_details else get_datetime(self.last_sync_of_checkin)
|
||||
)
|
||||
|
||||
# check if shift is found for 1 day before the last sync of checkin
|
||||
# absentees are auto-marked 1 day after the shift to wait for any manual attendance records
|
||||
prev_shift = get_employee_shift(employee, last_shift_time - timedelta(days=1), True, "reverse")
|
||||
if prev_shift:
|
||||
end_date = (
|
||||
min(prev_shift.start_datetime.date(), relieving_date)
|
||||
@ -131,28 +166,21 @@ class ShiftType(Document):
|
||||
else prev_shift.start_datetime.date()
|
||||
)
|
||||
else:
|
||||
return
|
||||
holiday_list_name = self.holiday_list
|
||||
if not holiday_list_name:
|
||||
holiday_list_name = get_holiday_list_for_employee(employee, False)
|
||||
dates = get_filtered_date_list(employee, start_date, end_date, holiday_list=holiday_list_name)
|
||||
for date in dates:
|
||||
shift_details = get_employee_shift(employee, date, True)
|
||||
if shift_details and shift_details.shift_type.name == self.name:
|
||||
mark_attendance(employee, date, "Absent", self.name)
|
||||
# no shift found
|
||||
return None, None
|
||||
return start_date, end_date
|
||||
|
||||
def get_assigned_employee(self, from_date=None, consider_default_shift=False):
|
||||
filters = {"start_date": (">", from_date), "shift_type": self.name, "docstatus": "1"}
|
||||
if not from_date:
|
||||
del filters["start_date"]
|
||||
filters = {"shift_type": self.name, "docstatus": "1"}
|
||||
if from_date:
|
||||
filters["start_date"] = (">", from_date)
|
||||
|
||||
assigned_employees = frappe.get_all("Shift Assignment", "employee", filters, as_list=True)
|
||||
assigned_employees = [x[0] for x in assigned_employees]
|
||||
assigned_employees = frappe.get_all("Shift Assignment", filters=filters, pluck="employee")
|
||||
|
||||
if consider_default_shift:
|
||||
filters = {"default_shift": self.name, "status": ["!=", "Inactive"]}
|
||||
default_shift_employees = frappe.get_all("Employee", "name", filters, as_list=True)
|
||||
default_shift_employees = [x[0] for x in default_shift_employees]
|
||||
default_shift_employees = frappe.get_all("Employee", filters=filters, pluck="name")
|
||||
|
||||
return list(set(assigned_employees + default_shift_employees))
|
||||
return assigned_employees
|
||||
|
||||
@ -162,42 +190,3 @@ def process_auto_attendance_for_all_shifts():
|
||||
for shift in shift_list:
|
||||
doc = frappe.get_doc("Shift Type", shift[0])
|
||||
doc.process_auto_attendance()
|
||||
|
||||
|
||||
def get_filtered_date_list(
|
||||
employee, start_date, end_date, filter_attendance=True, holiday_list=None
|
||||
):
|
||||
"""Returns a list of dates after removing the dates with attendance and holidays"""
|
||||
base_dates_query = """select adddate(%(start_date)s, t2.i*100 + t1.i*10 + t0.i) selected_date from
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t0,
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t1,
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t2"""
|
||||
condition_query = ""
|
||||
if filter_attendance:
|
||||
condition_query += """ and a.selected_date not in (
|
||||
select attendance_date from `tabAttendance`
|
||||
where docstatus = 1 and employee = %(employee)s
|
||||
and attendance_date between %(start_date)s and %(end_date)s)"""
|
||||
if holiday_list:
|
||||
condition_query += """ and a.selected_date not in (
|
||||
select holiday_date from `tabHoliday` where parenttype = 'Holiday List' and
|
||||
parentfield = 'holidays' and parent = %(holiday_list)s
|
||||
and holiday_date between %(start_date)s and %(end_date)s)"""
|
||||
|
||||
dates = frappe.db.sql(
|
||||
"""select * from
|
||||
({base_dates_query}) as a
|
||||
where a.selected_date <= %(end_date)s {condition_query}
|
||||
""".format(
|
||||
base_dates_query=base_dates_query, condition_query=condition_query
|
||||
),
|
||||
{
|
||||
"employee": employee,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"holiday_list": holiday_list,
|
||||
},
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
return [getdate(date[0]) for date in dates]
|
||||
|
@ -2,7 +2,381 @@
|
||||
# See license.txt
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, get_time, get_year_ending, get_year_start, getdate, now_datetime
|
||||
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
|
||||
from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
|
||||
from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
|
||||
|
||||
|
||||
class TestShiftType(unittest.TestCase):
|
||||
pass
|
||||
class TestShiftType(FrappeTestCase):
|
||||
def setUp(self):
|
||||
frappe.db.delete("Shift Type")
|
||||
frappe.db.delete("Shift Assignment")
|
||||
frappe.db.delete("Employee Checkin")
|
||||
frappe.db.delete("Attendance")
|
||||
|
||||
from_date = get_year_start(getdate())
|
||||
to_date = get_year_ending(getdate())
|
||||
self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
|
||||
|
||||
def test_mark_attendance(self):
|
||||
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
|
||||
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
|
||||
shift_type = setup_shift_type()
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:00:00"))
|
||||
log_in = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_in.shift, shift_type.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("12:00:00"))
|
||||
log_out = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_out.shift, shift_type.name)
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"shift": shift_type.name}, ["status", "name"], as_dict=True
|
||||
)
|
||||
self.assertEqual(attendance.status, "Present")
|
||||
|
||||
def test_entry_and_exit_grace(self):
|
||||
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
|
||||
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
|
||||
# doesn't mark late entry until 60 mins after shift start i.e. till 9
|
||||
# doesn't mark late entry until 60 mins before shift end i.e. 11
|
||||
shift_type = setup_shift_type(
|
||||
enable_entry_grace_period=1,
|
||||
enable_exit_grace_period=1,
|
||||
late_entry_grace_period=60,
|
||||
early_exit_grace_period=60,
|
||||
)
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("09:30:00"))
|
||||
log_in = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_in.shift, shift_type.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("10:30:00"))
|
||||
log_out = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_out.shift, shift_type.name)
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance",
|
||||
{"shift": shift_type.name},
|
||||
["status", "name", "late_entry", "early_exit"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(attendance.status, "Present")
|
||||
self.assertEqual(attendance.late_entry, 1)
|
||||
self.assertEqual(attendance.early_exit, 1)
|
||||
|
||||
def test_working_hours_threshold_for_half_day(self):
|
||||
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
|
||||
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
shift_type = setup_shift_type(shift_type="Half Day Test", working_hours_threshold_for_half_day=2)
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:00:00"))
|
||||
log_in = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_in.shift, shift_type.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("09:30:00"))
|
||||
log_out = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_out.shift, shift_type.name)
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
|
||||
)
|
||||
self.assertEqual(attendance.status, "Half Day")
|
||||
self.assertEqual(attendance.working_hours, 1.5)
|
||||
|
||||
def test_working_hours_threshold_for_absent(self):
|
||||
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
|
||||
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
shift_type = setup_shift_type(shift_type="Absent Test", working_hours_threshold_for_absent=2)
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:00:00"))
|
||||
log_in = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_in.shift, shift_type.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("09:30:00"))
|
||||
log_out = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_out.shift, shift_type.name)
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
|
||||
)
|
||||
self.assertEqual(attendance.status, "Absent")
|
||||
self.assertEqual(attendance.working_hours, 1.5)
|
||||
|
||||
def test_working_hours_threshold_for_absent_and_half_day_1(self):
|
||||
# considers half day over absent
|
||||
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
|
||||
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
shift_type = setup_shift_type(
|
||||
shift_type="Half Day + Absent Test",
|
||||
working_hours_threshold_for_half_day=1,
|
||||
working_hours_threshold_for_absent=2,
|
||||
)
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:00:00"))
|
||||
log_in = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_in.shift, shift_type.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:45:00"))
|
||||
log_out = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_out.shift, shift_type.name)
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
|
||||
)
|
||||
self.assertEqual(attendance.status, "Half Day")
|
||||
self.assertEqual(attendance.working_hours, 0.75)
|
||||
|
||||
def test_working_hours_threshold_for_absent_and_half_day_2(self):
|
||||
# considers absent over half day
|
||||
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
|
||||
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
shift_type = setup_shift_type(
|
||||
shift_type="Half Day + Absent Test",
|
||||
working_hours_threshold_for_half_day=1,
|
||||
working_hours_threshold_for_absent=2,
|
||||
)
|
||||
date = getdate()
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:00:00"))
|
||||
log_in = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_in.shift, shift_type.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("09:30:00"))
|
||||
log_out = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_out.shift, shift_type.name)
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
attendance = frappe.db.get_value("Attendance", {"shift": shift_type.name}, "status")
|
||||
self.assertEqual(attendance, "Absent")
|
||||
|
||||
def test_mark_absent_for_dates_with_no_attendance(self):
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
shift_type = setup_shift_type(shift_type="Test Absent with no Attendance")
|
||||
|
||||
# absentees are auto-marked one day after to wait for any manual attendance records
|
||||
date = add_days(getdate(), -1)
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"attendance_date": date, "employee": employee}, "status"
|
||||
)
|
||||
self.assertEqual(attendance, "Absent")
|
||||
|
||||
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
|
||||
def test_skip_marking_absent_on_a_holiday(self):
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
shift_type = setup_shift_type(shift_type="Test Absent with no Attendance")
|
||||
shift_type.holiday_list = None
|
||||
shift_type.save()
|
||||
|
||||
# should not mark any attendance if no shift assignment is created
|
||||
shift_type.process_auto_attendance()
|
||||
attendance = frappe.db.get_value("Attendance", {"employee": employee}, "status")
|
||||
self.assertIsNone(attendance)
|
||||
|
||||
first_sunday = get_first_sunday(self.holiday_list, for_date=getdate())
|
||||
make_shift_assignment(shift_type.name, employee, first_sunday)
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"attendance_date": first_sunday, "employee": employee}, "status"
|
||||
)
|
||||
self.assertIsNone(attendance)
|
||||
|
||||
def test_get_start_and_end_dates(self):
|
||||
date = getdate()
|
||||
|
||||
doj = add_days(date, -30)
|
||||
relieving_date = add_days(date, -5)
|
||||
employee = make_employee(
|
||||
"test_employee_dates@example.com",
|
||||
company="_Test Company",
|
||||
date_of_joining=doj,
|
||||
relieving_date=relieving_date,
|
||||
)
|
||||
shift_type = setup_shift_type(
|
||||
shift_type="Test Absent with no Attendance", process_attendance_after=add_days(doj, 2)
|
||||
)
|
||||
|
||||
make_shift_assignment(shift_type.name, employee, add_days(date, -25))
|
||||
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
# should not mark absent before shift assignment/process attendance after date
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"attendance_date": doj, "employee": employee}, "name"
|
||||
)
|
||||
self.assertIsNone(attendance)
|
||||
|
||||
# mark absent on Relieving Date
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"attendance_date": relieving_date, "employee": employee}, "status"
|
||||
)
|
||||
self.assertEquals(attendance, "Absent")
|
||||
|
||||
# should not mark absent after Relieving Date
|
||||
attendance = frappe.db.get_value(
|
||||
"Attendance", {"attendance_date": add_days(relieving_date, 1), "employee": employee}, "name"
|
||||
)
|
||||
self.assertIsNone(attendance)
|
||||
|
||||
def test_skip_auto_attendance_for_duplicate_record(self):
|
||||
# Skip auto attendance in case of duplicate attendance record
|
||||
from erpnext.hr.doctype.attendance.attendance import mark_attendance
|
||||
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
|
||||
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
|
||||
shift_type = setup_shift_type()
|
||||
date = getdate()
|
||||
|
||||
# mark attendance
|
||||
mark_attendance(employee, date, "Present")
|
||||
make_shift_assignment(shift_type.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("08:00:00"))
|
||||
log_in = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_in.shift, shift_type.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("12:00:00"))
|
||||
log_out = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_out.shift, shift_type.name)
|
||||
|
||||
# auto attendance should skip marking
|
||||
shift_type.process_auto_attendance()
|
||||
|
||||
log_in.reload()
|
||||
log_out.reload()
|
||||
self.assertEqual(log_in.skip_auto_attendance, 1)
|
||||
self.assertEqual(log_out.skip_auto_attendance, 1)
|
||||
|
||||
def test_skip_auto_attendance_for_overlapping_shift(self):
|
||||
# Skip auto attendance in case of overlapping shift attendance record
|
||||
# this case won't occur in case of shift assignment, since it will not allow overlapping shifts to be assigned
|
||||
# can happen if manual attendance records are created
|
||||
from erpnext.hr.doctype.attendance.attendance import mark_attendance
|
||||
from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
|
||||
|
||||
employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
|
||||
shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
|
||||
shift_2 = setup_shift_type(shift_type="Shift 2", start_time="09:30:00", end_time="11:00:00")
|
||||
|
||||
date = getdate()
|
||||
|
||||
# mark attendance
|
||||
mark_attendance(employee, date, "Present", shift=shift_1.name)
|
||||
make_shift_assignment(shift_2.name, employee, date)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("09:30:00"))
|
||||
log_in = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_in.shift, shift_2.name)
|
||||
|
||||
timestamp = datetime.combine(date, get_time("11:00:00"))
|
||||
log_out = make_checkin(employee, timestamp)
|
||||
self.assertEqual(log_out.shift, shift_2.name)
|
||||
|
||||
# auto attendance should be skipped for shift 2
|
||||
# since it is already marked for overlapping shift 1
|
||||
shift_2.process_auto_attendance()
|
||||
|
||||
log_in.reload()
|
||||
log_out.reload()
|
||||
self.assertEqual(log_in.skip_auto_attendance, 1)
|
||||
self.assertEqual(log_out.skip_auto_attendance, 1)
|
||||
|
||||
|
||||
def setup_shift_type(**args):
|
||||
args = frappe._dict(args)
|
||||
date = getdate()
|
||||
|
||||
shift_type = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Type",
|
||||
"__newname": args.shift_type or "_Test Shift",
|
||||
"start_time": "08:00:00",
|
||||
"end_time": "12:00:00",
|
||||
"enable_auto_attendance": 1,
|
||||
"determine_check_in_and_check_out": "Alternating entries as IN and OUT during the same shift",
|
||||
"working_hours_calculation_based_on": "First Check-in and Last Check-out",
|
||||
"begin_check_in_before_shift_start_time": 60,
|
||||
"allow_check_out_after_shift_end_time": 60,
|
||||
"process_attendance_after": add_days(date, -2),
|
||||
"last_sync_of_checkin": now_datetime() + timedelta(days=1),
|
||||
}
|
||||
)
|
||||
|
||||
holiday_list = "Employee Checkin Test Holiday List"
|
||||
if not frappe.db.exists("Holiday List", "Employee Checkin Test Holiday List"):
|
||||
holiday_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Holiday List",
|
||||
"holiday_list_name": "Employee Checkin Test Holiday List",
|
||||
"from_date": get_year_start(date),
|
||||
"to_date": get_year_ending(date),
|
||||
}
|
||||
).insert()
|
||||
holiday_list = holiday_list.name
|
||||
|
||||
shift_type.holiday_list = holiday_list
|
||||
shift_type.update(args)
|
||||
shift_type.save()
|
||||
|
||||
return shift_type
|
||||
|
||||
|
||||
def make_shift_assignment(shift_type, employee, start_date, end_date=None):
|
||||
shift_assignment = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shift Assignment",
|
||||
"shift_type": shift_type,
|
||||
"company": "_Test Company",
|
||||
"employee": employee,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
}
|
||||
).insert()
|
||||
shift_assignment.submit()
|
||||
|
||||
return shift_assignment
|
||||
|
@ -66,8 +66,7 @@ frappe.query_reports["Monthly Attendance Sheet"] = {
|
||||
"Default": 0,
|
||||
}
|
||||
],
|
||||
|
||||
"onload": function() {
|
||||
onload: function() {
|
||||
return frappe.call({
|
||||
method: "erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet.get_attendance_years",
|
||||
callback: function(r) {
|
||||
@ -78,5 +77,25 @@ frappe.query_reports["Monthly Attendance Sheet"] = {
|
||||
year_filter.set_input(year_filter.df.default);
|
||||
}
|
||||
});
|
||||
},
|
||||
formatter: function(value, row, column, data, default_formatter) {
|
||||
value = default_formatter(value, row, column, data);
|
||||
const summarized_view = frappe.query_report.get_filter_value('summarized_view');
|
||||
const group_by = frappe.query_report.get_filter_value('group_by');
|
||||
|
||||
if (!summarized_view) {
|
||||
if ((group_by && column.colIndex > 3) || (!group_by && column.colIndex > 2)) {
|
||||
if (value == 'P' || value == 'WFH')
|
||||
value = "<span style='color:green'>" + value + "</span>";
|
||||
else if (value == 'A')
|
||||
value = "<span style='color:red'>" + value + "</span>";
|
||||
else if (value == 'HD')
|
||||
value = "<span style='color:orange'>" + value + "</span>";
|
||||
else if (value == 'L')
|
||||
value = "<span style='color:#318AD8'>" + value + "</span>";
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
@ -3,365 +3,618 @@
|
||||
|
||||
|
||||
from calendar import monthrange
|
||||
from itertools import groupby
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Count, Extract, Sum
|
||||
from frappe.utils import cint, cstr, getdate
|
||||
|
||||
Filters = frappe._dict
|
||||
|
||||
status_map = {
|
||||
"Present": "P",
|
||||
"Absent": "A",
|
||||
"Half Day": "HD",
|
||||
"Holiday": "<b>H</b>",
|
||||
"Weekly Off": "<b>WO</b>",
|
||||
"On Leave": "L",
|
||||
"Present": "P",
|
||||
"Work From Home": "WFH",
|
||||
"On Leave": "L",
|
||||
"Holiday": "H",
|
||||
"Weekly Off": "WO",
|
||||
}
|
||||
|
||||
day_abbr = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
def execute(filters: Optional[Filters] = None) -> Tuple:
|
||||
filters = frappe._dict(filters or {})
|
||||
|
||||
if filters.hide_year_field == 1:
|
||||
filters.year = 2020
|
||||
if not (filters.month and filters.year):
|
||||
frappe.throw(_("Please select month and year."))
|
||||
|
||||
conditions, filters = get_conditions(filters)
|
||||
columns, days = get_columns(filters)
|
||||
att_map = get_attendance_list(conditions, filters)
|
||||
if not att_map:
|
||||
attendance_map = get_attendance_map(filters)
|
||||
if not attendance_map:
|
||||
frappe.msgprint(_("No attendance records found."), alert=True, indicator="orange")
|
||||
return [], [], None, None
|
||||
|
||||
columns = get_columns(filters)
|
||||
data = get_data(filters, attendance_map)
|
||||
|
||||
if not data:
|
||||
frappe.msgprint(
|
||||
_("No attendance records found for this criteria."), alert=True, indicator="orange"
|
||||
)
|
||||
return columns, [], None, None
|
||||
|
||||
if filters.group_by:
|
||||
emp_map, group_by_parameters = get_employee_details(filters.group_by, filters.company)
|
||||
holiday_list = []
|
||||
for parameter in group_by_parameters:
|
||||
h_list = [
|
||||
emp_map[parameter][d]["holiday_list"]
|
||||
for d in emp_map[parameter]
|
||||
if emp_map[parameter][d]["holiday_list"]
|
||||
]
|
||||
holiday_list += h_list
|
||||
else:
|
||||
emp_map = get_employee_details(filters.group_by, filters.company)
|
||||
holiday_list = [emp_map[d]["holiday_list"] for d in emp_map if emp_map[d]["holiday_list"]]
|
||||
message = get_message() if not filters.summarized_view else ""
|
||||
chart = get_chart_data(attendance_map, filters)
|
||||
|
||||
default_holiday_list = frappe.get_cached_value(
|
||||
"Company", filters.get("company"), "default_holiday_list"
|
||||
)
|
||||
holiday_list.append(default_holiday_list)
|
||||
holiday_list = list(set(holiday_list))
|
||||
holiday_map = get_holiday(holiday_list, filters["month"])
|
||||
|
||||
data = []
|
||||
|
||||
leave_types = frappe.db.get_list("Leave Type")
|
||||
leave_list = None
|
||||
if filters.summarized_view:
|
||||
leave_list = [d.name + ":Float:120" for d in leave_types]
|
||||
columns.extend(leave_list)
|
||||
columns.extend([_("Total Late Entries") + ":Float:120", _("Total Early Exits") + ":Float:120"])
|
||||
|
||||
if filters.group_by:
|
||||
emp_att_map = {}
|
||||
for parameter in group_by_parameters:
|
||||
emp_map_set = set([key for key in emp_map[parameter].keys()])
|
||||
att_map_set = set([key for key in att_map.keys()])
|
||||
if att_map_set & emp_map_set:
|
||||
parameter_row = ["<b>" + parameter + "</b>"] + [
|
||||
"" for day in range(filters["total_days_in_month"] + 2)
|
||||
]
|
||||
data.append(parameter_row)
|
||||
record, emp_att_data = add_data(
|
||||
emp_map[parameter],
|
||||
att_map,
|
||||
filters,
|
||||
holiday_map,
|
||||
conditions,
|
||||
default_holiday_list,
|
||||
leave_types=leave_types,
|
||||
)
|
||||
emp_att_map.update(emp_att_data)
|
||||
data += record
|
||||
else:
|
||||
record, emp_att_map = add_data(
|
||||
emp_map,
|
||||
att_map,
|
||||
filters,
|
||||
holiday_map,
|
||||
conditions,
|
||||
default_holiday_list,
|
||||
leave_types=leave_types,
|
||||
)
|
||||
data += record
|
||||
|
||||
chart_data = get_chart_data(emp_att_map, days)
|
||||
|
||||
return columns, data, None, chart_data
|
||||
return columns, data, message, chart
|
||||
|
||||
|
||||
def get_chart_data(emp_att_map, days):
|
||||
labels = []
|
||||
datasets = [
|
||||
{"name": "Absent", "values": []},
|
||||
{"name": "Present", "values": []},
|
||||
{"name": "Leave", "values": []},
|
||||
]
|
||||
for idx, day in enumerate(days, start=0):
|
||||
p = day.replace("::65", "")
|
||||
labels.append(day.replace("::65", ""))
|
||||
total_absent_on_day = 0
|
||||
total_leave_on_day = 0
|
||||
total_present_on_day = 0
|
||||
total_holiday = 0
|
||||
for emp in emp_att_map.keys():
|
||||
if emp_att_map[emp][idx]:
|
||||
if emp_att_map[emp][idx] == "A":
|
||||
total_absent_on_day += 1
|
||||
if emp_att_map[emp][idx] in ["P", "WFH"]:
|
||||
total_present_on_day += 1
|
||||
if emp_att_map[emp][idx] == "HD":
|
||||
total_present_on_day += 0.5
|
||||
total_leave_on_day += 0.5
|
||||
if emp_att_map[emp][idx] == "L":
|
||||
total_leave_on_day += 1
|
||||
def get_message() -> str:
|
||||
message = ""
|
||||
colors = ["green", "red", "orange", "green", "#318AD8", "", ""]
|
||||
|
||||
datasets[0]["values"].append(total_absent_on_day)
|
||||
datasets[1]["values"].append(total_present_on_day)
|
||||
datasets[2]["values"].append(total_leave_on_day)
|
||||
count = 0
|
||||
for status, abbr in status_map.items():
|
||||
message += f"""
|
||||
<span style='border-left: 2px solid {colors[count]}; padding-right: 12px; padding-left: 5px; margin-right: 3px;'>
|
||||
{status} - {abbr}
|
||||
</span>
|
||||
"""
|
||||
count += 1
|
||||
|
||||
chart = {"data": {"labels": labels, "datasets": datasets}}
|
||||
|
||||
chart["type"] = "line"
|
||||
|
||||
return chart
|
||||
return message
|
||||
|
||||
|
||||
def add_data(
|
||||
employee_map, att_map, filters, holiday_map, conditions, default_holiday_list, leave_types=None
|
||||
):
|
||||
|
||||
record = []
|
||||
emp_att_map = {}
|
||||
for emp in employee_map:
|
||||
emp_det = employee_map.get(emp)
|
||||
if not emp_det or emp not in att_map:
|
||||
continue
|
||||
|
||||
row = []
|
||||
if filters.group_by:
|
||||
row += [" "]
|
||||
row += [emp, emp_det.employee_name]
|
||||
|
||||
total_p = total_a = total_l = total_h = total_um = 0.0
|
||||
emp_status_map = []
|
||||
for day in range(filters["total_days_in_month"]):
|
||||
status = None
|
||||
status = att_map.get(emp).get(day + 1)
|
||||
|
||||
if status is None and holiday_map:
|
||||
emp_holiday_list = emp_det.holiday_list if emp_det.holiday_list else default_holiday_list
|
||||
|
||||
if emp_holiday_list in holiday_map:
|
||||
for idx, ele in enumerate(holiday_map[emp_holiday_list]):
|
||||
if day + 1 == holiday_map[emp_holiday_list][idx][0]:
|
||||
if holiday_map[emp_holiday_list][idx][1]:
|
||||
status = "Weekly Off"
|
||||
else:
|
||||
status = "Holiday"
|
||||
total_h += 1
|
||||
|
||||
abbr = status_map.get(status, "")
|
||||
emp_status_map.append(abbr)
|
||||
|
||||
if filters.summarized_view:
|
||||
if status == "Present" or status == "Work From Home":
|
||||
total_p += 1
|
||||
elif status == "Absent":
|
||||
total_a += 1
|
||||
elif status == "On Leave":
|
||||
total_l += 1
|
||||
elif status == "Half Day":
|
||||
total_p += 0.5
|
||||
total_a += 0.5
|
||||
total_l += 0.5
|
||||
elif not status:
|
||||
total_um += 1
|
||||
|
||||
if not filters.summarized_view:
|
||||
row += emp_status_map
|
||||
|
||||
if filters.summarized_view:
|
||||
row += [total_p, total_l, total_a, total_h, total_um]
|
||||
|
||||
if not filters.get("employee"):
|
||||
filters.update({"employee": emp})
|
||||
conditions += " and employee = %(employee)s"
|
||||
elif not filters.get("employee") == emp:
|
||||
filters.update({"employee": emp})
|
||||
|
||||
if filters.summarized_view:
|
||||
leave_details = frappe.db.sql(
|
||||
"""select leave_type, status, count(*) as count from `tabAttendance`\
|
||||
where leave_type is not NULL %s group by leave_type, status"""
|
||||
% conditions,
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
time_default_counts = frappe.db.sql(
|
||||
"""select (select count(*) from `tabAttendance` where \
|
||||
late_entry = 1 %s) as late_entry_count, (select count(*) from tabAttendance where \
|
||||
early_exit = 1 %s) as early_exit_count"""
|
||||
% (conditions, conditions),
|
||||
filters,
|
||||
)
|
||||
|
||||
leaves = {}
|
||||
for d in leave_details:
|
||||
if d.status == "Half Day":
|
||||
d.count = d.count * 0.5
|
||||
if d.leave_type in leaves:
|
||||
leaves[d.leave_type] += d.count
|
||||
else:
|
||||
leaves[d.leave_type] = d.count
|
||||
|
||||
for d in leave_types:
|
||||
if d.name in leaves:
|
||||
row.append(leaves[d.name])
|
||||
else:
|
||||
row.append("0.0")
|
||||
|
||||
row.extend([time_default_counts[0][0], time_default_counts[0][1]])
|
||||
emp_att_map[emp] = emp_status_map
|
||||
record.append(row)
|
||||
|
||||
return record, emp_att_map
|
||||
|
||||
|
||||
def get_columns(filters):
|
||||
|
||||
def get_columns(filters: Filters) -> List[Dict]:
|
||||
columns = []
|
||||
|
||||
if filters.group_by:
|
||||
columns = [_(filters.group_by) + ":Link/Branch:120"]
|
||||
columns.append(
|
||||
{
|
||||
"label": _(filters.group_by),
|
||||
"fieldname": frappe.scrub(filters.group_by),
|
||||
"fieldtype": "Link",
|
||||
"options": "Branch",
|
||||
"width": 120,
|
||||
}
|
||||
)
|
||||
|
||||
columns += [_("Employee") + ":Link/Employee:120", _("Employee Name") + ":Data/:120"]
|
||||
days = []
|
||||
for day in range(filters["total_days_in_month"]):
|
||||
date = str(filters.year) + "-" + str(filters.month) + "-" + str(day + 1)
|
||||
day_name = day_abbr[getdate(date).weekday()]
|
||||
days.append(cstr(day + 1) + " " + day_name + "::65")
|
||||
if not filters.summarized_view:
|
||||
columns += days
|
||||
|
||||
if filters.summarized_view:
|
||||
columns += [
|
||||
_("Total Present") + ":Float:120",
|
||||
_("Total Leaves") + ":Float:120",
|
||||
_("Total Absent") + ":Float:120",
|
||||
_("Total Holidays") + ":Float:120",
|
||||
_("Unmarked Days") + ":Float:120",
|
||||
columns.extend(
|
||||
[
|
||||
{
|
||||
"label": _("Employee"),
|
||||
"fieldname": "employee",
|
||||
"fieldtype": "Link",
|
||||
"options": "Employee",
|
||||
"width": 135,
|
||||
},
|
||||
{"label": _("Employee Name"), "fieldname": "employee_name", "fieldtype": "Data", "width": 120},
|
||||
]
|
||||
return columns, days
|
||||
|
||||
|
||||
def get_attendance_list(conditions, filters):
|
||||
attendance_list = frappe.db.sql(
|
||||
"""select employee, day(attendance_date) as day_of_month,
|
||||
status from tabAttendance where docstatus = 1 %s order by employee, attendance_date"""
|
||||
% conditions,
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
if not attendance_list:
|
||||
msgprint(_("No attendance record found"), alert=True, indicator="orange")
|
||||
if filters.summarized_view:
|
||||
columns.extend(
|
||||
[
|
||||
{
|
||||
"label": _("Total Present"),
|
||||
"fieldname": "total_present",
|
||||
"fieldtype": "Float",
|
||||
"width": 110,
|
||||
},
|
||||
{"label": _("Total Leaves"), "fieldname": "total_leaves", "fieldtype": "Float", "width": 110},
|
||||
{"label": _("Total Absent"), "fieldname": "total_absent", "fieldtype": "Float", "width": 110},
|
||||
{
|
||||
"label": _("Total Holidays"),
|
||||
"fieldname": "total_holidays",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Unmarked Days"),
|
||||
"fieldname": "unmarked_days",
|
||||
"fieldtype": "Float",
|
||||
"width": 130,
|
||||
},
|
||||
]
|
||||
)
|
||||
columns.extend(get_columns_for_leave_types())
|
||||
columns.extend(
|
||||
[
|
||||
{
|
||||
"label": _("Total Late Entries"),
|
||||
"fieldname": "total_late_entries",
|
||||
"fieldtype": "Float",
|
||||
"width": 140,
|
||||
},
|
||||
{
|
||||
"label": _("Total Early Exits"),
|
||||
"fieldname": "total_early_exits",
|
||||
"fieldtype": "Float",
|
||||
"width": 140,
|
||||
},
|
||||
]
|
||||
)
|
||||
else:
|
||||
columns.append({"label": _("Shift"), "fieldname": "shift", "fieldtype": "Data", "width": 120})
|
||||
columns.extend(get_columns_for_days(filters))
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def get_columns_for_leave_types() -> List[Dict]:
|
||||
leave_types = frappe.db.get_all("Leave Type", pluck="name")
|
||||
types = []
|
||||
for entry in leave_types:
|
||||
types.append(
|
||||
{"label": entry, "fieldname": frappe.scrub(entry), "fieldtype": "Float", "width": 120}
|
||||
)
|
||||
|
||||
return types
|
||||
|
||||
|
||||
def get_columns_for_days(filters: Filters) -> List[Dict]:
|
||||
total_days = get_total_days_in_month(filters)
|
||||
days = []
|
||||
|
||||
for day in range(1, total_days + 1):
|
||||
# forms the dates from selected year and month from filters
|
||||
date = "{}-{}-{}".format(cstr(filters.year), cstr(filters.month), cstr(day))
|
||||
# gets abbr from weekday number
|
||||
weekday = day_abbr[getdate(date).weekday()]
|
||||
# sets days as 1 Mon, 2 Tue, 3 Wed
|
||||
label = "{} {}".format(cstr(day), weekday)
|
||||
days.append({"label": label, "fieldtype": "Data", "fieldname": day, "width": 65})
|
||||
|
||||
return days
|
||||
|
||||
|
||||
def get_total_days_in_month(filters: Filters) -> int:
|
||||
return monthrange(cint(filters.year), cint(filters.month))[1]
|
||||
|
||||
|
||||
def get_data(filters: Filters, attendance_map: Dict) -> List[Dict]:
|
||||
employee_details, group_by_param_values = get_employee_related_details(
|
||||
filters.group_by, filters.company
|
||||
)
|
||||
holiday_map = get_holiday_map(filters)
|
||||
data = []
|
||||
|
||||
if filters.group_by:
|
||||
group_by_column = frappe.scrub(filters.group_by)
|
||||
|
||||
for value in group_by_param_values:
|
||||
if not value:
|
||||
continue
|
||||
|
||||
records = get_rows(employee_details[value], filters, holiday_map, attendance_map)
|
||||
|
||||
if records:
|
||||
data.append({group_by_column: frappe.bold(value)})
|
||||
data.extend(records)
|
||||
else:
|
||||
data = get_rows(employee_details, filters, holiday_map, attendance_map)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_attendance_map(filters: Filters) -> Dict:
|
||||
"""Returns a dictionary of employee wise attendance map as per shifts for all the days of the month like
|
||||
{
|
||||
'employee1': {
|
||||
'Morning Shift': {1: 'Present', 2: 'Absent', ...}
|
||||
'Evening Shift': {1: 'Absent', 2: 'Present', ...}
|
||||
},
|
||||
'employee2': {
|
||||
'Afternoon Shift': {1: 'Present', 2: 'Absent', ...}
|
||||
'Night Shift': {1: 'Absent', 2: 'Absent', ...}
|
||||
}
|
||||
}
|
||||
"""
|
||||
Attendance = frappe.qb.DocType("Attendance")
|
||||
query = (
|
||||
frappe.qb.from_(Attendance)
|
||||
.select(
|
||||
Attendance.employee,
|
||||
Extract("day", Attendance.attendance_date).as_("day_of_month"),
|
||||
Attendance.status,
|
||||
Attendance.shift,
|
||||
)
|
||||
.where(
|
||||
(Attendance.docstatus == 1)
|
||||
& (Attendance.company == filters.company)
|
||||
& (Extract("month", Attendance.attendance_date) == filters.month)
|
||||
& (Extract("year", Attendance.attendance_date) == filters.year)
|
||||
)
|
||||
)
|
||||
if filters.employee:
|
||||
query = query.where(Attendance.employee == filters.employee)
|
||||
query = query.orderby(Attendance.employee, Attendance.attendance_date)
|
||||
|
||||
attendance_list = query.run(as_dict=1)
|
||||
attendance_map = {}
|
||||
|
||||
att_map = {}
|
||||
for d in attendance_list:
|
||||
att_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, "")
|
||||
att_map[d.employee][d.day_of_month] = d.status
|
||||
attendance_map.setdefault(d.employee, frappe._dict()).setdefault(d.shift, frappe._dict())
|
||||
attendance_map[d.employee][d.shift][d.day_of_month] = d.status
|
||||
|
||||
return att_map
|
||||
return attendance_map
|
||||
|
||||
|
||||
def get_conditions(filters):
|
||||
if not (filters.get("month") and filters.get("year")):
|
||||
msgprint(_("Please select month and year"), raise_exception=1)
|
||||
|
||||
filters["total_days_in_month"] = monthrange(cint(filters.year), cint(filters.month))[1]
|
||||
|
||||
conditions = " and month(attendance_date) = %(month)s and year(attendance_date) = %(year)s"
|
||||
|
||||
if filters.get("company"):
|
||||
conditions += " and company = %(company)s"
|
||||
if filters.get("employee"):
|
||||
conditions += " and employee = %(employee)s"
|
||||
|
||||
return conditions, filters
|
||||
|
||||
|
||||
def get_employee_details(group_by, company):
|
||||
emp_map = {}
|
||||
query = """select name, employee_name, designation, department, branch, company,
|
||||
holiday_list from `tabEmployee` where company = %s """ % frappe.db.escape(
|
||||
company
|
||||
def get_employee_related_details(group_by: str, company: str) -> Tuple[Dict, List]:
|
||||
"""Returns
|
||||
1. nested dict for employee details
|
||||
2. list of values for the group by filter
|
||||
"""
|
||||
Employee = frappe.qb.DocType("Employee")
|
||||
query = (
|
||||
frappe.qb.from_(Employee)
|
||||
.select(
|
||||
Employee.name,
|
||||
Employee.employee_name,
|
||||
Employee.designation,
|
||||
Employee.grade,
|
||||
Employee.department,
|
||||
Employee.branch,
|
||||
Employee.company,
|
||||
Employee.holiday_list,
|
||||
)
|
||||
.where(Employee.company == company)
|
||||
)
|
||||
|
||||
if group_by:
|
||||
group_by = group_by.lower()
|
||||
query += " order by " + group_by + " ASC"
|
||||
query = query.orderby(group_by)
|
||||
|
||||
employee_details = frappe.db.sql(query, as_dict=1)
|
||||
employee_details = query.run(as_dict=True)
|
||||
|
||||
group_by_param_values = []
|
||||
emp_map = {}
|
||||
|
||||
group_by_parameters = []
|
||||
if group_by:
|
||||
for parameter, employees in groupby(employee_details, key=lambda d: d[group_by]):
|
||||
group_by_param_values.append(parameter)
|
||||
emp_map.setdefault(parameter, frappe._dict())
|
||||
|
||||
group_by_parameters = list(
|
||||
set(detail.get(group_by, "") for detail in employee_details if detail.get(group_by, ""))
|
||||
)
|
||||
for parameter in group_by_parameters:
|
||||
emp_map[parameter] = {}
|
||||
|
||||
for d in employee_details:
|
||||
if group_by and len(group_by_parameters):
|
||||
if d.get(group_by, None):
|
||||
|
||||
emp_map[d.get(group_by)][d.name] = d
|
||||
else:
|
||||
emp_map[d.name] = d
|
||||
|
||||
if not group_by:
|
||||
return emp_map
|
||||
for emp in employees:
|
||||
emp_map[parameter][emp.name] = emp
|
||||
else:
|
||||
return emp_map, group_by_parameters
|
||||
for emp in employee_details:
|
||||
emp_map[emp.name] = emp
|
||||
|
||||
return emp_map, group_by_param_values
|
||||
|
||||
|
||||
def get_holiday(holiday_list, month):
|
||||
def get_holiday_map(filters: Filters) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
Returns a dict of holidays falling in the filter month and year
|
||||
with list name as key and list of holidays as values like
|
||||
{
|
||||
'Holiday List 1': [
|
||||
{'day_of_month': '0' , 'weekly_off': 1},
|
||||
{'day_of_month': '1', 'weekly_off': 0}
|
||||
],
|
||||
'Holiday List 2': [
|
||||
{'day_of_month': '0' , 'weekly_off': 1},
|
||||
{'day_of_month': '1', 'weekly_off': 0}
|
||||
]
|
||||
}
|
||||
"""
|
||||
# add default holiday list too
|
||||
holiday_lists = frappe.db.get_all("Holiday List", pluck="name")
|
||||
default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list")
|
||||
holiday_lists.append(default_holiday_list)
|
||||
|
||||
holiday_map = frappe._dict()
|
||||
for d in holiday_list:
|
||||
if d:
|
||||
holiday_map.setdefault(
|
||||
d,
|
||||
frappe.db.sql(
|
||||
"""select day(holiday_date), weekly_off from `tabHoliday`
|
||||
where parent=%s and month(holiday_date)=%s""",
|
||||
(d, month),
|
||||
),
|
||||
Holiday = frappe.qb.DocType("Holiday")
|
||||
|
||||
for d in holiday_lists:
|
||||
if not d:
|
||||
continue
|
||||
|
||||
holidays = (
|
||||
frappe.qb.from_(Holiday)
|
||||
.select(Extract("day", Holiday.holiday_date).as_("day_of_month"), Holiday.weekly_off)
|
||||
.where(
|
||||
(Holiday.parent == d)
|
||||
& (Extract("month", Holiday.holiday_date) == filters.month)
|
||||
& (Extract("year", Holiday.holiday_date) == filters.year)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
holiday_map.setdefault(d, holidays)
|
||||
|
||||
return holiday_map
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_attendance_years():
|
||||
year_list = frappe.db.sql_list(
|
||||
"""select distinct YEAR(attendance_date) from tabAttendance ORDER BY YEAR(attendance_date) DESC"""
|
||||
def get_rows(
|
||||
employee_details: Dict, filters: Filters, holiday_map: Dict, attendance_map: Dict
|
||||
) -> List[Dict]:
|
||||
records = []
|
||||
default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list")
|
||||
|
||||
for employee, details in employee_details.items():
|
||||
emp_holiday_list = details.holiday_list or default_holiday_list
|
||||
holidays = holiday_map.get(emp_holiday_list)
|
||||
|
||||
if filters.summarized_view:
|
||||
attendance = get_attendance_status_for_summarized_view(employee, filters, holidays)
|
||||
if not attendance:
|
||||
continue
|
||||
|
||||
leave_summary = get_leave_summary(employee, filters)
|
||||
entry_exits_summary = get_entry_exits_summary(employee, filters)
|
||||
|
||||
row = {"employee": employee, "employee_name": details.employee_name}
|
||||
set_defaults_for_summarized_view(filters, row)
|
||||
row.update(attendance)
|
||||
row.update(leave_summary)
|
||||
row.update(entry_exits_summary)
|
||||
|
||||
records.append(row)
|
||||
else:
|
||||
employee_attendance = attendance_map.get(employee)
|
||||
if not employee_attendance:
|
||||
continue
|
||||
|
||||
attendance_for_employee = get_attendance_status_for_detailed_view(
|
||||
employee, filters, employee_attendance, holidays
|
||||
)
|
||||
# set employee details in the first row
|
||||
attendance_for_employee[0].update(
|
||||
{"employee": employee, "employee_name": details.employee_name}
|
||||
)
|
||||
|
||||
records.extend(attendance_for_employee)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def set_defaults_for_summarized_view(filters, row):
|
||||
for entry in get_columns(filters):
|
||||
if entry.get("fieldtype") == "Float":
|
||||
row[entry.get("fieldname")] = 0.0
|
||||
|
||||
|
||||
def get_attendance_status_for_summarized_view(
|
||||
employee: str, filters: Filters, holidays: List
|
||||
) -> Dict:
|
||||
"""Returns dict of attendance status for employee like
|
||||
{'total_present': 1.5, 'total_leaves': 0.5, 'total_absent': 13.5, 'total_holidays': 8, 'unmarked_days': 5}
|
||||
"""
|
||||
summary, attendance_days = get_attendance_summary_and_days(employee, filters)
|
||||
if not any(summary.values()):
|
||||
return {}
|
||||
|
||||
total_days = get_total_days_in_month(filters)
|
||||
total_holidays = total_unmarked_days = 0
|
||||
|
||||
for day in range(1, total_days + 1):
|
||||
if day in attendance_days:
|
||||
continue
|
||||
|
||||
status = get_holiday_status(day, holidays)
|
||||
if status in ["Weekly Off", "Holiday"]:
|
||||
total_holidays += 1
|
||||
elif not status:
|
||||
total_unmarked_days += 1
|
||||
|
||||
return {
|
||||
"total_present": summary.total_present + summary.total_half_days,
|
||||
"total_leaves": summary.total_leaves + summary.total_half_days,
|
||||
"total_absent": summary.total_absent + summary.total_half_days,
|
||||
"total_holidays": total_holidays,
|
||||
"unmarked_days": total_unmarked_days,
|
||||
}
|
||||
|
||||
|
||||
def get_attendance_summary_and_days(employee: str, filters: Filters) -> Tuple[Dict, List]:
|
||||
Attendance = frappe.qb.DocType("Attendance")
|
||||
|
||||
present_case = (
|
||||
frappe.qb.terms.Case()
|
||||
.when(((Attendance.status == "Present") | (Attendance.status == "Work From Home")), 1)
|
||||
.else_(0)
|
||||
)
|
||||
if not year_list:
|
||||
sum_present = Sum(present_case).as_("total_present")
|
||||
|
||||
absent_case = frappe.qb.terms.Case().when(Attendance.status == "Absent", 1).else_(0)
|
||||
sum_absent = Sum(absent_case).as_("total_absent")
|
||||
|
||||
leave_case = frappe.qb.terms.Case().when(Attendance.status == "On Leave", 1).else_(0)
|
||||
sum_leave = Sum(leave_case).as_("total_leaves")
|
||||
|
||||
half_day_case = frappe.qb.terms.Case().when(Attendance.status == "Half Day", 0.5).else_(0)
|
||||
sum_half_day = Sum(half_day_case).as_("total_half_days")
|
||||
|
||||
summary = (
|
||||
frappe.qb.from_(Attendance)
|
||||
.select(
|
||||
sum_present,
|
||||
sum_absent,
|
||||
sum_leave,
|
||||
sum_half_day,
|
||||
)
|
||||
.where(
|
||||
(Attendance.docstatus == 1)
|
||||
& (Attendance.employee == employee)
|
||||
& (Attendance.company == filters.company)
|
||||
& (Extract("month", Attendance.attendance_date) == filters.month)
|
||||
& (Extract("year", Attendance.attendance_date) == filters.year)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
days = (
|
||||
frappe.qb.from_(Attendance)
|
||||
.select(Extract("day", Attendance.attendance_date).as_("day_of_month"))
|
||||
.distinct()
|
||||
.where(
|
||||
(Attendance.docstatus == 1)
|
||||
& (Attendance.employee == employee)
|
||||
& (Attendance.company == filters.company)
|
||||
& (Extract("month", Attendance.attendance_date) == filters.month)
|
||||
& (Extract("year", Attendance.attendance_date) == filters.year)
|
||||
)
|
||||
).run(pluck=True)
|
||||
|
||||
return summary[0], days
|
||||
|
||||
|
||||
def get_attendance_status_for_detailed_view(
|
||||
employee: str, filters: Filters, employee_attendance: Dict, holidays: List
|
||||
) -> List[Dict]:
|
||||
"""Returns list of shift-wise attendance status for employee
|
||||
[
|
||||
{'shift': 'Morning Shift', 1: 'A', 2: 'P', 3: 'A'....},
|
||||
{'shift': 'Evening Shift', 1: 'P', 2: 'A', 3: 'P'....}
|
||||
]
|
||||
"""
|
||||
total_days = get_total_days_in_month(filters)
|
||||
attendance_values = []
|
||||
|
||||
for shift, status_dict in employee_attendance.items():
|
||||
row = {"shift": shift}
|
||||
|
||||
for day in range(1, total_days + 1):
|
||||
status = status_dict.get(day)
|
||||
if status is None and holidays:
|
||||
status = get_holiday_status(day, holidays)
|
||||
|
||||
abbr = status_map.get(status, "")
|
||||
row[day] = abbr
|
||||
|
||||
attendance_values.append(row)
|
||||
|
||||
return attendance_values
|
||||
|
||||
|
||||
def get_holiday_status(day: int, holidays: List) -> str:
|
||||
status = None
|
||||
for holiday in holidays:
|
||||
if day == holiday.get("day_of_month"):
|
||||
if holiday.get("weekly_off"):
|
||||
status = "Weekly Off"
|
||||
else:
|
||||
status = "Holiday"
|
||||
break
|
||||
return status
|
||||
|
||||
|
||||
def get_leave_summary(employee: str, filters: Filters) -> Dict[str, float]:
|
||||
"""Returns a dict of leave type and corresponding leaves taken by employee like:
|
||||
{'leave_without_pay': 1.0, 'sick_leave': 2.0}
|
||||
"""
|
||||
Attendance = frappe.qb.DocType("Attendance")
|
||||
day_case = frappe.qb.terms.Case().when(Attendance.status == "Half Day", 0.5).else_(1)
|
||||
sum_leave_days = Sum(day_case).as_("leave_days")
|
||||
|
||||
leave_details = (
|
||||
frappe.qb.from_(Attendance)
|
||||
.select(Attendance.leave_type, sum_leave_days)
|
||||
.where(
|
||||
(Attendance.employee == employee)
|
||||
& (Attendance.docstatus == 1)
|
||||
& (Attendance.company == filters.company)
|
||||
& ((Attendance.leave_type.isnotnull()) | (Attendance.leave_type != ""))
|
||||
& (Extract("month", Attendance.attendance_date) == filters.month)
|
||||
& (Extract("year", Attendance.attendance_date) == filters.year)
|
||||
)
|
||||
.groupby(Attendance.leave_type)
|
||||
).run(as_dict=True)
|
||||
|
||||
leaves = {}
|
||||
for d in leave_details:
|
||||
leave_type = frappe.scrub(d.leave_type)
|
||||
leaves[leave_type] = d.leave_days
|
||||
|
||||
return leaves
|
||||
|
||||
|
||||
def get_entry_exits_summary(employee: str, filters: Filters) -> Dict[str, float]:
|
||||
"""Returns total late entries and total early exits for employee like:
|
||||
{'total_late_entries': 5, 'total_early_exits': 2}
|
||||
"""
|
||||
Attendance = frappe.qb.DocType("Attendance")
|
||||
|
||||
late_entry_case = frappe.qb.terms.Case().when(Attendance.late_entry == "1", "1")
|
||||
count_late_entries = Count(late_entry_case).as_("total_late_entries")
|
||||
|
||||
early_exit_case = frappe.qb.terms.Case().when(Attendance.early_exit == "1", "1")
|
||||
count_early_exits = Count(early_exit_case).as_("total_early_exits")
|
||||
|
||||
entry_exits = (
|
||||
frappe.qb.from_(Attendance)
|
||||
.select(count_late_entries, count_early_exits)
|
||||
.where(
|
||||
(Attendance.docstatus == 1)
|
||||
& (Attendance.employee == employee)
|
||||
& (Attendance.company == filters.company)
|
||||
& (Extract("month", Attendance.attendance_date) == filters.month)
|
||||
& (Extract("year", Attendance.attendance_date) == filters.year)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
return entry_exits[0]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_attendance_years() -> str:
|
||||
"""Returns all the years for which attendance records exist"""
|
||||
Attendance = frappe.qb.DocType("Attendance")
|
||||
year_list = (
|
||||
frappe.qb.from_(Attendance)
|
||||
.select(Extract("year", Attendance.attendance_date).as_("year"))
|
||||
.distinct()
|
||||
).run(as_dict=True)
|
||||
|
||||
if year_list:
|
||||
year_list.sort(key=lambda d: d.year, reverse=True)
|
||||
else:
|
||||
year_list = [getdate().year]
|
||||
|
||||
return "\n".join(str(year) for year in year_list)
|
||||
return "\n".join(cstr(entry.year) for entry in year_list)
|
||||
|
||||
|
||||
def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict:
|
||||
days = get_columns_for_days(filters)
|
||||
labels = []
|
||||
absent = []
|
||||
present = []
|
||||
leave = []
|
||||
|
||||
for day in days:
|
||||
labels.append(day["label"])
|
||||
total_absent_on_day = total_leaves_on_day = total_present_on_day = 0
|
||||
|
||||
for employee, attendance_dict in attendance_map.items():
|
||||
for shift, attendance in attendance_dict.items():
|
||||
attendance_on_day = attendance.get(day["fieldname"])
|
||||
|
||||
if attendance_on_day == "Absent":
|
||||
total_absent_on_day += 1
|
||||
elif attendance_on_day in ["Present", "Work From Home"]:
|
||||
total_present_on_day += 1
|
||||
elif attendance_on_day == "Half Day":
|
||||
total_present_on_day += 0.5
|
||||
total_leaves_on_day += 0.5
|
||||
elif attendance_on_day == "On Leave":
|
||||
total_leaves_on_day += 1
|
||||
|
||||
absent.append(total_absent_on_day)
|
||||
present.append(total_present_on_day)
|
||||
leave.append(total_leaves_on_day)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{"name": "Absent", "values": absent},
|
||||
{"name": "Present", "values": present},
|
||||
{"name": "Leave", "values": leave},
|
||||
],
|
||||
},
|
||||
"type": "line",
|
||||
"colors": ["red", "green", "blue"],
|
||||
}
|
||||
|
@ -1,18 +1,32 @@
|
||||
import frappe
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import now_datetime
|
||||
from frappe.utils import get_year_ending, get_year_start, getdate, now_datetime
|
||||
|
||||
from erpnext.hr.doctype.attendance.attendance import mark_attendance
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
|
||||
from erpnext.hr.doctype.leave_application.test_leave_application import make_allocation_record
|
||||
from erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet import execute
|
||||
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
|
||||
make_holiday_list,
|
||||
make_leave_application,
|
||||
)
|
||||
|
||||
test_dependencies = ["Shift Type"]
|
||||
|
||||
|
||||
class TestMonthlyAttendanceSheet(FrappeTestCase):
|
||||
def setUp(self):
|
||||
self.employee = make_employee("test_employee@example.com")
|
||||
frappe.db.delete("Attendance", {"employee": self.employee})
|
||||
self.employee = make_employee("test_employee@example.com", company="_Test Company")
|
||||
frappe.db.delete("Attendance")
|
||||
|
||||
date = getdate()
|
||||
from_date = get_year_start(date)
|
||||
to_date = get_year_ending(date)
|
||||
make_holiday_list(from_date=from_date, to_date=to_date)
|
||||
|
||||
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
|
||||
def test_monthly_attendance_sheet_report(self):
|
||||
now = now_datetime()
|
||||
previous_month = now.month - 1
|
||||
@ -33,14 +47,203 @@ class TestMonthlyAttendanceSheet(FrappeTestCase):
|
||||
}
|
||||
)
|
||||
report = execute(filters=filters)
|
||||
employees = report[1][0]
|
||||
|
||||
record = report[1][0]
|
||||
datasets = report[3]["data"]["datasets"]
|
||||
absent = datasets[0]["values"]
|
||||
present = datasets[1]["values"]
|
||||
leaves = datasets[2]["values"]
|
||||
|
||||
# ensure correct attendance is reflect on the report
|
||||
self.assertIn(self.employee, employees)
|
||||
# ensure correct attendance is reflected on the report
|
||||
self.assertEqual(self.employee, record.get("employee"))
|
||||
self.assertEqual(absent[0], 1)
|
||||
self.assertEqual(present[1], 1)
|
||||
self.assertEqual(leaves[2], 1)
|
||||
|
||||
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
|
||||
def test_monthly_attendance_sheet_with_detailed_view(self):
|
||||
now = now_datetime()
|
||||
previous_month = now.month - 1
|
||||
previous_month_first = now.replace(day=1).replace(month=previous_month).date()
|
||||
|
||||
company = frappe.db.get_value("Employee", self.employee, "company")
|
||||
|
||||
# attendance with shift
|
||||
mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
|
||||
mark_attendance(
|
||||
self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
|
||||
)
|
||||
|
||||
# attendance without shift
|
||||
mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
|
||||
mark_attendance(self.employee, previous_month_first + relativedelta(days=3), "Present")
|
||||
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"month": previous_month,
|
||||
"year": now.year,
|
||||
"company": company,
|
||||
}
|
||||
)
|
||||
report = execute(filters=filters)
|
||||
|
||||
day_shift_row = report[1][0]
|
||||
row_without_shift = report[1][1]
|
||||
|
||||
self.assertEqual(day_shift_row["shift"], "Day Shift")
|
||||
self.assertEqual(day_shift_row[1], "A") # absent on the 1st day of the month
|
||||
self.assertEqual(day_shift_row[2], "P") # present on the 2nd day
|
||||
|
||||
self.assertEqual(row_without_shift["shift"], None)
|
||||
self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day
|
||||
self.assertEqual(row_without_shift[4], "P") # present on the 4th day
|
||||
|
||||
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
|
||||
def test_monthly_attendance_sheet_with_summarized_view(self):
|
||||
now = now_datetime()
|
||||
previous_month = now.month - 1
|
||||
previous_month_first = now.replace(day=1).replace(month=previous_month).date()
|
||||
|
||||
company = frappe.db.get_value("Employee", self.employee, "company")
|
||||
|
||||
# attendance with shift
|
||||
mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
|
||||
mark_attendance(
|
||||
self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
|
||||
)
|
||||
mark_attendance(
|
||||
self.employee, previous_month_first + relativedelta(days=2), "Half Day"
|
||||
) # half day
|
||||
|
||||
mark_attendance(
|
||||
self.employee, previous_month_first + relativedelta(days=3), "Present"
|
||||
) # attendance without shift
|
||||
mark_attendance(
|
||||
self.employee, previous_month_first + relativedelta(days=4), "Present", late_entry=1
|
||||
) # late entry
|
||||
mark_attendance(
|
||||
self.employee, previous_month_first + relativedelta(days=5), "Present", early_exit=1
|
||||
) # early exit
|
||||
|
||||
leave_application = get_leave_application(self.employee)
|
||||
|
||||
filters = frappe._dict(
|
||||
{"month": previous_month, "year": now.year, "company": company, "summarized_view": 1}
|
||||
)
|
||||
report = execute(filters=filters)
|
||||
|
||||
row = report[1][0]
|
||||
self.assertEqual(row["employee"], self.employee)
|
||||
|
||||
# 4 present + half day absent 0.5
|
||||
self.assertEqual(row["total_present"], 4.5)
|
||||
# 1 present + half day absent 0.5
|
||||
self.assertEqual(row["total_absent"], 1.5)
|
||||
# leave days + half day leave 0.5
|
||||
self.assertEqual(row["total_leaves"], leave_application.total_leave_days + 0.5)
|
||||
|
||||
self.assertEqual(row["_test_leave_type"], leave_application.total_leave_days)
|
||||
self.assertEqual(row["total_late_entries"], 1)
|
||||
self.assertEqual(row["total_early_exits"], 1)
|
||||
|
||||
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
|
||||
def test_attendance_with_group_by_filter(self):
|
||||
now = now_datetime()
|
||||
previous_month = now.month - 1
|
||||
previous_month_first = now.replace(day=1).replace(month=previous_month).date()
|
||||
|
||||
company = frappe.db.get_value("Employee", self.employee, "company")
|
||||
|
||||
# attendance with shift
|
||||
mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
|
||||
mark_attendance(
|
||||
self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
|
||||
)
|
||||
|
||||
# attendance without shift
|
||||
mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
|
||||
mark_attendance(self.employee, previous_month_first + relativedelta(days=3), "Present")
|
||||
|
||||
filters = frappe._dict(
|
||||
{"month": previous_month, "year": now.year, "company": company, "group_by": "Department"}
|
||||
)
|
||||
report = execute(filters=filters)
|
||||
|
||||
department = frappe.db.get_value("Employee", self.employee, "department")
|
||||
department_row = report[1][0]
|
||||
self.assertIn(department, department_row["department"])
|
||||
|
||||
day_shift_row = report[1][1]
|
||||
row_without_shift = report[1][2]
|
||||
|
||||
self.assertEqual(day_shift_row["shift"], "Day Shift")
|
||||
self.assertEqual(day_shift_row[1], "A") # absent on the 1st day of the month
|
||||
self.assertEqual(day_shift_row[2], "P") # present on the 2nd day
|
||||
|
||||
self.assertEqual(row_without_shift["shift"], None)
|
||||
self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day
|
||||
self.assertEqual(row_without_shift[4], "P") # present on the 4th day
|
||||
|
||||
def test_attendance_with_employee_filter(self):
|
||||
now = now_datetime()
|
||||
previous_month = now.month - 1
|
||||
previous_month_first = now.replace(day=1).replace(month=previous_month).date()
|
||||
|
||||
company = frappe.db.get_value("Employee", self.employee, "company")
|
||||
|
||||
# mark different attendance status on first 3 days of previous month
|
||||
mark_attendance(self.employee, previous_month_first, "Absent")
|
||||
mark_attendance(self.employee, previous_month_first + relativedelta(days=1), "Present")
|
||||
mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
|
||||
|
||||
filters = frappe._dict(
|
||||
{"month": previous_month, "year": now.year, "company": company, "employee": self.employee}
|
||||
)
|
||||
report = execute(filters=filters)
|
||||
|
||||
record = report[1][0]
|
||||
datasets = report[3]["data"]["datasets"]
|
||||
absent = datasets[0]["values"]
|
||||
present = datasets[1]["values"]
|
||||
leaves = datasets[2]["values"]
|
||||
|
||||
# ensure correct attendance is reflected on the report
|
||||
self.assertEqual(self.employee, record.get("employee"))
|
||||
self.assertEqual(absent[0], 1)
|
||||
self.assertEqual(present[1], 1)
|
||||
self.assertEqual(leaves[2], 1)
|
||||
|
||||
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
|
||||
def test_validations(self):
|
||||
# validation error for filters without month and year
|
||||
self.assertRaises(frappe.ValidationError, execute_report_with_invalid_filters)
|
||||
|
||||
# execute report without attendance record
|
||||
now = now_datetime()
|
||||
previous_month = now.month - 1
|
||||
|
||||
company = frappe.db.get_value("Employee", self.employee, "company")
|
||||
filters = frappe._dict(
|
||||
{"month": previous_month, "year": now.year, "company": company, "group_by": "Department"}
|
||||
)
|
||||
report = execute(filters=filters)
|
||||
self.assertEqual(report, ([], [], None, None))
|
||||
|
||||
|
||||
def get_leave_application(employee):
|
||||
now = now_datetime()
|
||||
previous_month = now.month - 1
|
||||
|
||||
date = getdate()
|
||||
year_start = getdate(get_year_start(date))
|
||||
year_end = getdate(get_year_ending(date))
|
||||
make_allocation_record(employee=employee, from_date=year_start, to_date=year_end)
|
||||
|
||||
from_date = now.replace(day=7).replace(month=previous_month).date()
|
||||
to_date = now.replace(day=8).replace(month=previous_month).date()
|
||||
return make_leave_application(employee, from_date, to_date, "_Test Leave Type")
|
||||
|
||||
|
||||
def execute_report_with_invalid_filters():
|
||||
filters = frappe._dict({"company": "_Test Company", "group_by": "Department"})
|
||||
execute(filters=filters)
|
||||
|
@ -330,6 +330,17 @@ def update_previous_leave_allocation(
|
||||
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
|
||||
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
|
||||
|
||||
if e_leave_type.based_on_date_of_joining:
|
||||
text = _("allocated {0} leave(s) via scheduler on {1} based on the date of joining").format(
|
||||
frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
|
||||
)
|
||||
else:
|
||||
text = _("allocated {0} leave(s) via scheduler on {1}").format(
|
||||
frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
|
||||
)
|
||||
|
||||
allocation.add_comment(comment_type="Info", text=text)
|
||||
|
||||
|
||||
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
|
||||
earned_leaves = 0.0
|
||||
|
@ -140,26 +140,6 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap
|
||||
}
|
||||
}
|
||||
|
||||
start_date(doc, cdt, cdn) {
|
||||
this.set_no_of_visits(doc, cdt, cdn);
|
||||
}
|
||||
|
||||
end_date(doc, cdt, cdn) {
|
||||
this.set_no_of_visits(doc, cdt, cdn);
|
||||
}
|
||||
|
||||
periodicity(doc, cdt, cdn) {
|
||||
this.set_no_of_visits(doc, cdt, cdn);
|
||||
}
|
||||
|
||||
set_no_of_visits(doc, cdt, cdn) {
|
||||
var item = frappe.get_doc(cdt, cdn);
|
||||
let me = this;
|
||||
if (item.start_date && item.periodicity) {
|
||||
me.frm.call('validate_end_date_visits');
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extend_cscript(cur_frm.cscript, new erpnext.maintenance.MaintenanceSchedule({frm: cur_frm}));
|
||||
|
@ -213,6 +213,26 @@ class MaintenanceSchedule(TransactionBase):
|
||||
if chk:
|
||||
throw(_("Maintenance Schedule {0} exists against {1}").format(chk[0][0], d.sales_order))
|
||||
|
||||
def validate_items_table_change(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not doc_before_save:
|
||||
return
|
||||
for prev_item, item in zip(doc_before_save.items, self.items):
|
||||
fields = [
|
||||
"item_code",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"periodicity",
|
||||
"sales_person",
|
||||
"no_of_visits",
|
||||
"serial_no",
|
||||
]
|
||||
for field in fields:
|
||||
b_doc = prev_item.as_dict()
|
||||
doc = item.as_dict()
|
||||
if cstr(b_doc[field]) != cstr(doc[field]):
|
||||
return True
|
||||
|
||||
def validate_no_of_visits(self):
|
||||
return len(self.schedules) != sum(d.no_of_visits for d in self.items)
|
||||
|
||||
@ -221,7 +241,7 @@ class MaintenanceSchedule(TransactionBase):
|
||||
self.validate_maintenance_detail()
|
||||
self.validate_dates_with_periodicity()
|
||||
self.validate_sales_order()
|
||||
if not self.schedules or self.validate_no_of_visits():
|
||||
if not self.schedules or self.validate_items_table_change() or self.validate_no_of_visits():
|
||||
self.generate_schedule()
|
||||
|
||||
def on_update(self):
|
||||
|
@ -123,6 +123,36 @@ class TestMaintenanceSchedule(unittest.TestCase):
|
||||
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_schedule_with_serials(self):
|
||||
# Checks whether serials are automatically updated when changing in items table.
|
||||
# Also checks if other fields trigger generate schdeule if changed in items table.
|
||||
item_code = "_Test Serial Item"
|
||||
make_serial_item_with_serial(item_code)
|
||||
ms = make_maintenance_schedule(item_code=item_code, serial_no="TEST001, TEST002")
|
||||
ms.save()
|
||||
|
||||
# Before Save
|
||||
self.assertEqual(ms.schedules[0].serial_no, "TEST001, TEST002")
|
||||
self.assertEqual(ms.schedules[0].sales_person, "Sales Team")
|
||||
self.assertEqual(len(ms.schedules), 4)
|
||||
self.assertFalse(ms.validate_items_table_change())
|
||||
# After Save
|
||||
ms.items[0].serial_no = "TEST001"
|
||||
ms.items[0].sales_person = "_Test Sales Person"
|
||||
ms.items[0].no_of_visits = 2
|
||||
self.assertTrue(ms.validate_items_table_change())
|
||||
ms.save()
|
||||
self.assertEqual(ms.schedules[0].serial_no, "TEST001")
|
||||
self.assertEqual(ms.schedules[0].sales_person, "_Test Sales Person")
|
||||
self.assertEqual(len(ms.schedules), 2)
|
||||
# When user manually deleted a row from schedules table.
|
||||
ms.schedules.pop()
|
||||
self.assertEqual(len(ms.schedules), 1)
|
||||
ms.save()
|
||||
self.assertEqual(len(ms.schedules), 2)
|
||||
|
||||
frappe.db.rollback()
|
||||
|
||||
|
||||
def make_serial_item_with_serial(item_code):
|
||||
serial_item_doc = create_item(item_code, is_stock_item=1)
|
||||
|
@ -12,6 +12,9 @@ frappe.ui.form.on('Maintenance Visit', {
|
||||
// filters for serial no based on item code
|
||||
if (frm.doc.maintenance_type === "Scheduled") {
|
||||
let item_code = frm.doc.purposes[0].item_code;
|
||||
if (!item_code) {
|
||||
return;
|
||||
}
|
||||
frappe.call({
|
||||
method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.get_serial_nos_from_schedule",
|
||||
args: {
|
||||
|
@ -109,7 +109,6 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "5",
|
||||
"depends_on": "eval:parent.doctype == 'BOM'",
|
||||
"fieldname": "base_operating_cost",
|
||||
"fieldtype": "Currency",
|
||||
@ -187,7 +186,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-03-10 06:19:08.462027",
|
||||
"modified": "2022-04-08 01:18:33.547481",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Operation",
|
||||
|
@ -73,10 +73,22 @@ frappe.ui.form.on('Job Card', {
|
||||
if (frm.doc.docstatus == 0 && !frm.is_new() &&
|
||||
(frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity)
|
||||
&& (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)) {
|
||||
frm.trigger("prepare_timer_buttons");
|
||||
|
||||
// if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started"
|
||||
// and if stock mvt for WIP is required
|
||||
if (frm.doc.work_order) {
|
||||
frappe.db.get_value('Work Order', frm.doc.work_order, ['skip_transfer', 'status'], (result) => {
|
||||
if (result.skip_transfer === 1 || result.status == 'In Process') {
|
||||
frm.trigger("prepare_timer_buttons");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
frm.trigger("prepare_timer_buttons");
|
||||
}
|
||||
}
|
||||
|
||||
frm.trigger("setup_quality_inspection");
|
||||
|
||||
if (frm.doc.work_order) {
|
||||
frappe.db.get_value('Work Order', frm.doc.work_order,
|
||||
'transfer_material_against').then((r) => {
|
||||
|
@ -462,6 +462,7 @@ class ProductionPlan(Document):
|
||||
work_order_data = {
|
||||
"wip_warehouse": default_warehouses.get("wip_warehouse"),
|
||||
"fg_warehouse": default_warehouses.get("fg_warehouse"),
|
||||
"company": self.get("company"),
|
||||
}
|
||||
|
||||
self.prepare_data_for_sub_assembly_items(row, work_order_data)
|
||||
@ -499,6 +500,7 @@ class ProductionPlan(Document):
|
||||
|
||||
for supplier, po_list in subcontracted_po.items():
|
||||
po = frappe.new_doc("Purchase Order")
|
||||
po.company = self.company
|
||||
po.supplier = supplier
|
||||
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
|
||||
po.is_subcontracted = 1
|
||||
|
@ -1144,6 +1144,56 @@ class TestWorkOrder(FrappeTestCase):
|
||||
for index, row in enumerate(ste_manu.get("items"), start=1):
|
||||
self.assertEqual(index, row.idx)
|
||||
|
||||
@change_settings(
|
||||
"Manufacturing Settings",
|
||||
{"backflush_raw_materials_based_on": "Material Transferred for Manufacture"},
|
||||
)
|
||||
def test_work_order_multiple_material_transfer(self):
|
||||
"""
|
||||
Test transferring multiple RMs in separate Stock Entries.
|
||||
"""
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
|
||||
test_stock_entry.make_stock_entry( # stock up RM
|
||||
item_code="_Test Item",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=1,
|
||||
basic_rate=5000.0,
|
||||
)
|
||||
test_stock_entry.make_stock_entry( # stock up RM
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=2,
|
||||
basic_rate=1000.0,
|
||||
)
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 1)
|
||||
)
|
||||
del transfer_entry.get("items")[0] # transfer only one RM
|
||||
transfer_entry.submit()
|
||||
|
||||
# WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 0)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
final_transfer_entry = frappe.get_doc( # transfer last RM with For Quantity = 0
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
|
||||
)
|
||||
final_transfer_entry.save()
|
||||
|
||||
self.assertEqual(final_transfer_entry.fg_completed_qty, 0.0)
|
||||
self.assertEqual(final_transfer_entry.items[0].qty, 1)
|
||||
|
||||
final_transfer_entry.submit()
|
||||
work_order.reload()
|
||||
|
||||
# WO's "Material Transferred for Mfg" shows all is transferred, no RM is pending
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
|
||||
def update_job_card(job_card, jc_qty=None):
|
||||
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
|
||||
|
@ -540,8 +540,10 @@ erpnext.work_order = {
|
||||
|| frm.doc.transfer_material_against == 'Job Card') ? 0 : 1;
|
||||
|
||||
if (show_start_btn) {
|
||||
if ((flt(doc.material_transferred_for_manufacturing) < flt(doc.qty))
|
||||
&& frm.doc.status != 'Stopped') {
|
||||
let pending_to_transfer = frm.doc.required_items.some(
|
||||
item => flt(item.transferred_qty) < flt(item.required_qty)
|
||||
);
|
||||
if (pending_to_transfer && frm.doc.status != 'Stopped') {
|
||||
frm.has_start_btn = true;
|
||||
frm.add_custom_button(__('Create Pick List'), function() {
|
||||
erpnext.work_order.create_pick_list(frm);
|
||||
|
@ -1186,7 +1186,11 @@ def make_stock_entry(work_order_id, purpose, qty=None):
|
||||
stock_entry.from_bom = 1
|
||||
stock_entry.bom_no = work_order.bom_no
|
||||
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
|
||||
stock_entry.fg_completed_qty = qty or (flt(work_order.qty) - flt(work_order.produced_qty))
|
||||
# accept 0 qty as well
|
||||
stock_entry.fg_completed_qty = (
|
||||
qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty))
|
||||
)
|
||||
|
||||
if work_order.bom_no:
|
||||
stock_entry.inspection_required = frappe.db.get_value(
|
||||
"BOM", work_order.bom_no, "inspection_required"
|
||||
|
@ -262,8 +262,6 @@ erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 #17-01-2022
|
||||
erpnext.patches.v13_0.update_shipment_status
|
||||
erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting
|
||||
erpnext.patches.v12_0.add_ewaybill_validity_field
|
||||
erpnext.patches.v13_0.germany_make_custom_fields
|
||||
erpnext.patches.v13_0.germany_fill_debtor_creditor_number
|
||||
erpnext.patches.v13_0.set_pos_closing_as_failed
|
||||
erpnext.patches.v13_0.rename_stop_to_send_birthday_reminders
|
||||
execute:frappe.rename_doc("Workspace", "Loan Management", "Loans", force=True)
|
||||
@ -343,6 +341,7 @@ erpnext.patches.v14_0.delete_shopify_doctypes
|
||||
erpnext.patches.v14_0.delete_hub_doctypes
|
||||
erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022
|
||||
erpnext.patches.v14_0.delete_agriculture_doctypes
|
||||
erpnext.patches.v14_0.delete_datev_doctypes
|
||||
erpnext.patches.v14_0.rearrange_company_fields
|
||||
erpnext.patches.v14_0.update_leave_notification_template
|
||||
erpnext.patches.v14_0.restore_einvoice_fields
|
||||
@ -364,3 +363,5 @@ erpnext.patches.v13_0.add_cost_center_in_loans
|
||||
erpnext.patches.v13_0.set_return_against_in_pos_invoice_references
|
||||
erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items # 24-03-2022
|
||||
erpnext.patches.v13_0.update_expense_claim_status_for_paid_advances
|
||||
erpnext.patches.v13_0.create_gst_custom_fields_in_quotation
|
||||
erpnext.patches.v13_0.copy_custom_field_filters_to_website_item
|
||||
|
@ -0,0 +1,94 @@
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
|
||||
|
||||
|
||||
def execute():
|
||||
"Add Field Filters, that are not standard fields in Website Item, as Custom Fields."
|
||||
|
||||
def move_table_multiselect_data(docfield):
|
||||
"Copy child table data (Table Multiselect) from Item to Website Item for a docfield."
|
||||
table_multiselect_data = get_table_multiselect_data(docfield)
|
||||
field = docfield.fieldname
|
||||
|
||||
for row in table_multiselect_data:
|
||||
# add copied multiselect data rows in Website Item
|
||||
web_item = frappe.db.get_value("Website Item", {"item_code": row.parent})
|
||||
web_item_doc = frappe.get_doc("Website Item", web_item)
|
||||
|
||||
child_doc = frappe.new_doc(docfield.options, web_item_doc, field)
|
||||
|
||||
for field in ["name", "creation", "modified", "idx"]:
|
||||
row[field] = None
|
||||
|
||||
child_doc.update(row)
|
||||
|
||||
child_doc.parenttype = "Website Item"
|
||||
child_doc.parent = web_item
|
||||
|
||||
child_doc.insert()
|
||||
|
||||
def get_table_multiselect_data(docfield):
|
||||
child_table = frappe.qb.DocType(docfield.options)
|
||||
item = frappe.qb.DocType("Item")
|
||||
|
||||
table_multiselect_data = ( # query table data for field
|
||||
frappe.qb.from_(child_table)
|
||||
.join(item)
|
||||
.on(item.item_code == child_table.parent)
|
||||
.select(child_table.star)
|
||||
.where((child_table.parentfield == docfield.fieldname) & (item.published_in_website == 1))
|
||||
).run(as_dict=True)
|
||||
|
||||
return table_multiselect_data
|
||||
|
||||
settings = frappe.get_doc("E Commerce Settings")
|
||||
|
||||
if not (settings.enable_field_filters or settings.filter_fields):
|
||||
return
|
||||
|
||||
item_meta = frappe.get_meta("Item")
|
||||
valid_item_fields = [
|
||||
df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]
|
||||
]
|
||||
|
||||
web_item_meta = frappe.get_meta("Website Item")
|
||||
valid_web_item_fields = [
|
||||
df.fieldname for df in web_item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]
|
||||
]
|
||||
|
||||
for row in settings.filter_fields:
|
||||
# skip if illegal field
|
||||
if row.fieldname not in valid_item_fields:
|
||||
continue
|
||||
|
||||
# if Item field is not in Website Item, add it as a custom field
|
||||
if row.fieldname not in valid_web_item_fields:
|
||||
df = item_meta.get_field(row.fieldname)
|
||||
create_custom_field(
|
||||
"Website Item",
|
||||
dict(
|
||||
owner="Administrator",
|
||||
fieldname=df.fieldname,
|
||||
label=df.label,
|
||||
fieldtype=df.fieldtype,
|
||||
options=df.options,
|
||||
description=df.description,
|
||||
read_only=df.read_only,
|
||||
no_copy=df.no_copy,
|
||||
insert_after="on_backorder",
|
||||
),
|
||||
)
|
||||
|
||||
# map field values
|
||||
if df.fieldtype == "Table MultiSelect":
|
||||
move_table_multiselect_data(df)
|
||||
else:
|
||||
frappe.db.sql( # nosemgrep
|
||||
"""
|
||||
UPDATE `tabWebsite Item` wi, `tabItem` i
|
||||
SET wi.{0} = i.{0}
|
||||
WHERE wi.item_code = i.item_code
|
||||
""".format(
|
||||
row.fieldname
|
||||
)
|
||||
)
|
@ -0,0 +1,53 @@
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
|
||||
def execute():
|
||||
company = frappe.get_all("Company", filters={"country": "India"}, fields=["name"])
|
||||
if not company:
|
||||
return
|
||||
|
||||
sales_invoice_gst_fields = [
|
||||
dict(
|
||||
fieldname="billing_address_gstin",
|
||||
label="Billing Address GSTIN",
|
||||
fieldtype="Data",
|
||||
insert_after="customer_address",
|
||||
read_only=1,
|
||||
fetch_from="customer_address.gstin",
|
||||
print_hide=1,
|
||||
length=15,
|
||||
),
|
||||
dict(
|
||||
fieldname="customer_gstin",
|
||||
label="Customer GSTIN",
|
||||
fieldtype="Data",
|
||||
insert_after="shipping_address_name",
|
||||
fetch_from="shipping_address_name.gstin",
|
||||
print_hide=1,
|
||||
length=15,
|
||||
),
|
||||
dict(
|
||||
fieldname="place_of_supply",
|
||||
label="Place of Supply",
|
||||
fieldtype="Data",
|
||||
insert_after="customer_gstin",
|
||||
print_hide=1,
|
||||
read_only=1,
|
||||
length=50,
|
||||
),
|
||||
dict(
|
||||
fieldname="company_gstin",
|
||||
label="Company GSTIN",
|
||||
fieldtype="Data",
|
||||
insert_after="company_address",
|
||||
fetch_from="company_address.gstin",
|
||||
print_hide=1,
|
||||
read_only=1,
|
||||
length=15,
|
||||
),
|
||||
]
|
||||
|
||||
custom_fields = {"Quotation": sales_invoice_gst_fields}
|
||||
|
||||
create_custom_fields(custom_fields, update=True)
|
@ -1,36 +0,0 @@
|
||||
# Copyright (c) 2019, Frappe and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""Move account number into the new custom field debtor_creditor_number.
|
||||
|
||||
German companies used to use a dedicated payable/receivable account for
|
||||
every party to mimick party accounts in the external accounting software
|
||||
"DATEV". This is no longer necessary. The reference ID for DATEV will be
|
||||
stored in a new custom field "debtor_creditor_number".
|
||||
"""
|
||||
company_list = frappe.get_all("Company", filters={"country": "Germany"})
|
||||
|
||||
for company in company_list:
|
||||
party_account_list = frappe.get_all(
|
||||
"Party Account",
|
||||
filters={"company": company.name},
|
||||
fields=["name", "account", "debtor_creditor_number"],
|
||||
)
|
||||
for party_account in party_account_list:
|
||||
if (not party_account.account) or party_account.debtor_creditor_number:
|
||||
# account empty or debtor_creditor_number already filled
|
||||
continue
|
||||
|
||||
account_number = frappe.db.get_value("Account", party_account.account, "account_number")
|
||||
if not account_number:
|
||||
continue
|
||||
|
||||
frappe.db.set_value(
|
||||
"Party Account", party_account.name, "debtor_creditor_number", account_number
|
||||
)
|
||||
frappe.db.set_value("Party Account", party_account.name, "account", "")
|
@ -1,20 +0,0 @@
|
||||
# Copyright (c) 2019, Frappe and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.regional.germany.setup import make_custom_fields
|
||||
|
||||
|
||||
def execute():
|
||||
"""Execute the make_custom_fields method for german companies.
|
||||
|
||||
It is usually run once at setup of a new company. Since it's new, run it
|
||||
once for existing companies as well.
|
||||
"""
|
||||
company_list = frappe.get_all("Company", filters={"country": "Germany"})
|
||||
if not company_list:
|
||||
return
|
||||
|
||||
make_custom_fields()
|
@ -10,54 +10,58 @@ def execute():
|
||||
|
||||
frappe.reload_doc("hr", "doctype", "Leave Encashment")
|
||||
|
||||
additional_salaries = frappe.get_all(
|
||||
"Additional Salary",
|
||||
fields=["name", "salary_slip", "type", "salary_component"],
|
||||
filters={"salary_slip": ["!=", ""]},
|
||||
group_by="salary_slip",
|
||||
)
|
||||
leave_encashments = frappe.get_all(
|
||||
"Leave Encashment",
|
||||
fields=["name", "additional_salary"],
|
||||
filters={"additional_salary": ["!=", ""]},
|
||||
)
|
||||
employee_incentives = frappe.get_all(
|
||||
"Employee Incentive",
|
||||
fields=["name", "additional_salary"],
|
||||
filters={"additional_salary": ["!=", ""]},
|
||||
)
|
||||
|
||||
for incentive in employee_incentives:
|
||||
frappe.db.sql(
|
||||
""" UPDATE `tabAdditional Salary`
|
||||
SET ref_doctype = 'Employee Incentive', ref_docname = %s
|
||||
WHERE name = %s
|
||||
""",
|
||||
(incentive["name"], incentive["additional_salary"]),
|
||||
if frappe.db.has_column("Leave Encashment", "additional_salary"):
|
||||
leave_encashments = frappe.get_all(
|
||||
"Leave Encashment",
|
||||
fields=["name", "additional_salary"],
|
||||
filters={"additional_salary": ["!=", ""]},
|
||||
)
|
||||
|
||||
for leave_encashment in leave_encashments:
|
||||
frappe.db.sql(
|
||||
""" UPDATE `tabAdditional Salary`
|
||||
SET ref_doctype = 'Leave Encashment', ref_docname = %s
|
||||
WHERE name = %s
|
||||
""",
|
||||
(leave_encashment["name"], leave_encashment["additional_salary"]),
|
||||
)
|
||||
|
||||
salary_slips = [sal["salary_slip"] for sal in additional_salaries]
|
||||
|
||||
for salary in additional_salaries:
|
||||
comp_type = "earnings" if salary["type"] == "Earning" else "deductions"
|
||||
if salary["salary_slip"] and salary_slips.count(salary["salary_slip"]) == 1:
|
||||
for leave_encashment in leave_encashments:
|
||||
frappe.db.sql(
|
||||
"""
|
||||
UPDATE `tabSalary Detail`
|
||||
SET additional_salary = %s
|
||||
WHERE parenttype = 'Salary Slip'
|
||||
and parentfield = %s
|
||||
and parent = %s
|
||||
and salary_component = %s
|
||||
""" UPDATE `tabAdditional Salary`
|
||||
SET ref_doctype = 'Leave Encashment', ref_docname = %s
|
||||
WHERE name = %s
|
||||
""",
|
||||
(salary["name"], comp_type, salary["salary_slip"], salary["salary_component"]),
|
||||
(leave_encashment["name"], leave_encashment["additional_salary"]),
|
||||
)
|
||||
|
||||
if frappe.db.has_column("Employee Incentive", "additional_salary"):
|
||||
employee_incentives = frappe.get_all(
|
||||
"Employee Incentive",
|
||||
fields=["name", "additional_salary"],
|
||||
filters={"additional_salary": ["!=", ""]},
|
||||
)
|
||||
|
||||
for incentive in employee_incentives:
|
||||
frappe.db.sql(
|
||||
""" UPDATE `tabAdditional Salary`
|
||||
SET ref_doctype = 'Employee Incentive', ref_docname = %s
|
||||
WHERE name = %s
|
||||
""",
|
||||
(incentive["name"], incentive["additional_salary"]),
|
||||
)
|
||||
|
||||
if frappe.db.has_column("Additional Salary", "salary_slip"):
|
||||
additional_salaries = frappe.get_all(
|
||||
"Additional Salary",
|
||||
fields=["name", "salary_slip", "type", "salary_component"],
|
||||
filters={"salary_slip": ["!=", ""]},
|
||||
group_by="salary_slip",
|
||||
)
|
||||
|
||||
salary_slips = [sal["salary_slip"] for sal in additional_salaries]
|
||||
|
||||
for salary in additional_salaries:
|
||||
comp_type = "earnings" if salary["type"] == "Earning" else "deductions"
|
||||
if salary["salary_slip"] and salary_slips.count(salary["salary_slip"]) == 1:
|
||||
frappe.db.sql(
|
||||
"""
|
||||
UPDATE `tabSalary Detail`
|
||||
SET additional_salary = %s
|
||||
WHERE parenttype = 'Salary Slip'
|
||||
and parentfield = %s
|
||||
and parent = %s
|
||||
and salary_component = %s
|
||||
""",
|
||||
(salary["name"], comp_type, salary["salary_slip"], salary["salary_component"]),
|
||||
)
|
||||
|
13
erpnext/patches/v14_0/delete_datev_doctypes.py
Normal file
13
erpnext/patches/v14_0/delete_datev_doctypes.py
Normal file
@ -0,0 +1,13 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
install_apps = frappe.get_installed_apps()
|
||||
if "erpnext_datev_uo" in install_apps or "erpnext_datev" in install_apps:
|
||||
return
|
||||
|
||||
# doctypes
|
||||
frappe.delete_doc("DocType", "DATEV Settings", ignore_missing=True, force=True)
|
||||
|
||||
# reports
|
||||
frappe.delete_doc("Report", "DATEV", ignore_missing=True, force=True)
|
@ -24,7 +24,9 @@ class TestGratuity(unittest.TestCase):
|
||||
frappe.db.delete("Gratuity")
|
||||
frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"})
|
||||
|
||||
make_earning_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
|
||||
make_earning_salary_component(
|
||||
setup=True, test_tax=True, company_list=["_Test Company"], include_flexi_benefits=True
|
||||
)
|
||||
make_deduction_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
|
||||
|
||||
def test_get_last_salary_slip_should_return_none_for_new_employee(self):
|
||||
|
@ -952,8 +952,12 @@ class SalarySlip(TransactionBase):
|
||||
)
|
||||
|
||||
# Structured tax amount
|
||||
total_structured_tax_amount = self.calculate_tax_by_tax_slab(
|
||||
total_taxable_earnings_without_full_tax_addl_components, tax_slab
|
||||
eval_locals = self.get_data_for_eval()
|
||||
total_structured_tax_amount = calculate_tax_by_tax_slab(
|
||||
total_taxable_earnings_without_full_tax_addl_components,
|
||||
tax_slab,
|
||||
self.whitelisted_globals,
|
||||
eval_locals,
|
||||
)
|
||||
current_structured_tax_amount = (
|
||||
total_structured_tax_amount - previous_total_paid_taxes
|
||||
@ -962,7 +966,9 @@ class SalarySlip(TransactionBase):
|
||||
# Total taxable earnings with additional earnings with full tax
|
||||
full_tax_on_additional_earnings = 0.0
|
||||
if current_additional_earnings_with_full_tax:
|
||||
total_tax_amount = self.calculate_tax_by_tax_slab(total_taxable_earnings, tax_slab)
|
||||
total_tax_amount = calculate_tax_by_tax_slab(
|
||||
total_taxable_earnings, tax_slab, self.whitelisted_globals, eval_locals
|
||||
)
|
||||
full_tax_on_additional_earnings = total_tax_amount - total_structured_tax_amount
|
||||
|
||||
current_tax_amount = current_structured_tax_amount + full_tax_on_additional_earnings
|
||||
@ -1278,50 +1284,6 @@ class SalarySlip(TransactionBase):
|
||||
fields="SUM(amount) as total_amount",
|
||||
)[0].total_amount
|
||||
|
||||
def calculate_tax_by_tax_slab(self, annual_taxable_earning, tax_slab):
|
||||
data = self.get_data_for_eval()
|
||||
data.update({"annual_taxable_earning": annual_taxable_earning})
|
||||
tax_amount = 0
|
||||
for slab in tax_slab.slabs:
|
||||
cond = cstr(slab.condition).strip()
|
||||
if cond and not self.eval_tax_slab_condition(cond, data):
|
||||
continue
|
||||
if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
|
||||
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
|
||||
continue
|
||||
if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount:
|
||||
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
|
||||
elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount:
|
||||
tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * 0.01
|
||||
|
||||
# other taxes and charges on income tax
|
||||
for d in tax_slab.other_taxes_and_charges:
|
||||
if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning:
|
||||
continue
|
||||
|
||||
if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning:
|
||||
continue
|
||||
|
||||
tax_amount += tax_amount * flt(d.percent) / 100
|
||||
|
||||
return tax_amount
|
||||
|
||||
def eval_tax_slab_condition(self, condition, data):
|
||||
try:
|
||||
condition = condition.strip()
|
||||
if condition:
|
||||
return frappe.safe_eval(condition, self.whitelisted_globals, data)
|
||||
except NameError as err:
|
||||
frappe.throw(
|
||||
_("{0} <br> This error can be due to missing or deleted field.").format(err),
|
||||
title=_("Name error"),
|
||||
)
|
||||
except SyntaxError as err:
|
||||
frappe.throw(_("Syntax error in condition: {0}").format(err))
|
||||
except Exception as e:
|
||||
frappe.throw(_("Error in formula or condition: {0}").format(e))
|
||||
raise
|
||||
|
||||
def get_component_totals(self, component_type, depends_on_payment_days=0):
|
||||
joining_date, relieving_date = frappe.get_cached_value(
|
||||
"Employee", self.employee, ["date_of_joining", "relieving_date"]
|
||||
@ -1705,3 +1667,60 @@ def get_payroll_payable_account(company, payroll_entry):
|
||||
)
|
||||
|
||||
return payroll_payable_account
|
||||
|
||||
|
||||
def calculate_tax_by_tax_slab(
|
||||
annual_taxable_earning, tax_slab, eval_globals=None, eval_locals=None
|
||||
):
|
||||
eval_locals.update({"annual_taxable_earning": annual_taxable_earning})
|
||||
tax_amount = 0
|
||||
for slab in tax_slab.slabs:
|
||||
cond = cstr(slab.condition).strip()
|
||||
if cond and not eval_tax_slab_condition(cond, eval_globals, eval_locals):
|
||||
continue
|
||||
if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
|
||||
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
|
||||
continue
|
||||
if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount:
|
||||
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
|
||||
elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount:
|
||||
tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * 0.01
|
||||
|
||||
# other taxes and charges on income tax
|
||||
for d in tax_slab.other_taxes_and_charges:
|
||||
if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning:
|
||||
continue
|
||||
|
||||
if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning:
|
||||
continue
|
||||
|
||||
tax_amount += tax_amount * flt(d.percent) / 100
|
||||
|
||||
return tax_amount
|
||||
|
||||
|
||||
def eval_tax_slab_condition(condition, eval_globals=None, eval_locals=None):
|
||||
if not eval_globals:
|
||||
eval_globals = {
|
||||
"int": int,
|
||||
"float": float,
|
||||
"long": int,
|
||||
"round": round,
|
||||
"date": datetime.date,
|
||||
"getdate": getdate,
|
||||
}
|
||||
|
||||
try:
|
||||
condition = condition.strip()
|
||||
if condition:
|
||||
return frappe.safe_eval(condition, eval_globals, eval_locals)
|
||||
except NameError as err:
|
||||
frappe.throw(
|
||||
_("{0} <br> This error can be due to missing or deleted field.").format(err),
|
||||
title=_("Name error"),
|
||||
)
|
||||
except SyntaxError as err:
|
||||
frappe.throw(_("Syntax error in condition: {0} in Income Tax Slab").format(err))
|
||||
except Exception as e:
|
||||
frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(e))
|
||||
raise
|
||||
|
@ -772,6 +772,7 @@ class TestSalarySlip(unittest.TestCase):
|
||||
"Monthly",
|
||||
other_details={"max_benefits": 100000},
|
||||
test_tax=True,
|
||||
include_flexi_benefits=True,
|
||||
employee=employee,
|
||||
payroll_period=payroll_period,
|
||||
)
|
||||
@ -875,6 +876,7 @@ class TestSalarySlip(unittest.TestCase):
|
||||
"Monthly",
|
||||
other_details={"max_benefits": 100000},
|
||||
test_tax=True,
|
||||
include_flexi_benefits=True,
|
||||
employee=employee,
|
||||
payroll_period=payroll_period,
|
||||
)
|
||||
@ -1022,7 +1024,9 @@ def create_account(account_name, company, parent_account, account_type=None):
|
||||
return account
|
||||
|
||||
|
||||
def make_earning_salary_component(setup=False, test_tax=False, company_list=None):
|
||||
def make_earning_salary_component(
|
||||
setup=False, test_tax=False, company_list=None, include_flexi_benefits=False
|
||||
):
|
||||
data = [
|
||||
{
|
||||
"salary_component": "Basic Salary",
|
||||
@ -1043,7 +1047,7 @@ def make_earning_salary_component(setup=False, test_tax=False, company_list=None
|
||||
},
|
||||
{"salary_component": "Leave Encashment", "abbr": "LE", "type": "Earning"},
|
||||
]
|
||||
if test_tax:
|
||||
if include_flexi_benefits:
|
||||
data.extend(
|
||||
[
|
||||
{
|
||||
@ -1063,11 +1067,18 @@ def make_earning_salary_component(setup=False, test_tax=False, company_list=None
|
||||
"type": "Earning",
|
||||
"max_benefit_amount": 15000,
|
||||
},
|
||||
]
|
||||
)
|
||||
if test_tax:
|
||||
data.extend(
|
||||
[
|
||||
{"salary_component": "Performance Bonus", "abbr": "B", "type": "Earning"},
|
||||
]
|
||||
)
|
||||
|
||||
if setup or test_tax:
|
||||
make_salary_component(data, test_tax, company_list)
|
||||
|
||||
data.append(
|
||||
{
|
||||
"salary_component": "Basic Salary",
|
||||
|
@ -149,6 +149,7 @@ def make_salary_structure(
|
||||
company=None,
|
||||
currency=erpnext.get_default_currency(),
|
||||
payroll_period=None,
|
||||
include_flexi_benefits=False,
|
||||
):
|
||||
if test_tax:
|
||||
frappe.db.sql("""delete from `tabSalary Structure` where name=%s""", (salary_structure))
|
||||
@ -161,7 +162,10 @@ def make_salary_structure(
|
||||
"name": salary_structure,
|
||||
"company": company or erpnext.get_default_company(),
|
||||
"earnings": make_earning_salary_component(
|
||||
setup=True, test_tax=test_tax, company_list=["_Test Company"]
|
||||
setup=True,
|
||||
test_tax=test_tax,
|
||||
company_list=["_Test Company"],
|
||||
include_flexi_benefits=include_flexi_benefits,
|
||||
),
|
||||
"deductions": make_deduction_salary_component(
|
||||
setup=True, test_tax=test_tax, company_list=["_Test Company"]
|
||||
|
@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
/* eslint-disable */
|
||||
|
||||
frappe.query_reports["Income Tax Computation"] = {
|
||||
"filters": [
|
||||
{
|
||||
"fieldname":"company",
|
||||
"label": __("Company"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Company",
|
||||
"default": frappe.defaults.get_user_default("Company"),
|
||||
"width": "100px",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname":"payroll_period",
|
||||
"label": __("Payroll Period"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Payroll Period",
|
||||
"width": "100px",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname":"employee",
|
||||
"label": __("Employee"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Employee",
|
||||
"width": "100px"
|
||||
},
|
||||
{
|
||||
"fieldname":"department",
|
||||
"label": __("Department"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Department",
|
||||
"width": "100px",
|
||||
},
|
||||
{
|
||||
"fieldname":"consider_tax_exemption_declaration",
|
||||
"label": __("Consider Tax Exemption Declaration"),
|
||||
"fieldtype": "Check",
|
||||
"width": "180px"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,36 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"columns": [],
|
||||
"creation": "2022-02-17 17:19:30.921422",
|
||||
"disable_prepared_report": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"letter_head": "",
|
||||
"modified": "2022-02-23 13:07:30.347861",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Payroll",
|
||||
"name": "Income Tax Computation",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Salary Slip",
|
||||
"report_name": "Income Tax Computation",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Employee"
|
||||
},
|
||||
{
|
||||
"role": "HR User"
|
||||
},
|
||||
{
|
||||
"role": "HR Manager"
|
||||
},
|
||||
{
|
||||
"role": "Employee Self Service"
|
||||
}
|
||||
]
|
||||
}
|
@ -0,0 +1,513 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, flt, getdate, rounded
|
||||
|
||||
from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates
|
||||
from erpnext.payroll.doctype.salary_slip.salary_slip import calculate_tax_by_tax_slab
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
return IncomeTaxComputationReport(filters).run()
|
||||
|
||||
|
||||
class IncomeTaxComputationReport(object):
|
||||
def __init__(self, filters=None):
|
||||
self.filters = frappe._dict(filters or {})
|
||||
self.columns = []
|
||||
self.data = []
|
||||
self.employees = frappe._dict()
|
||||
self.payroll_period_start_date = None
|
||||
self.payroll_period_end_date = None
|
||||
if self.filters.payroll_period:
|
||||
self.payroll_period_start_date, self.payroll_period_end_date = frappe.db.get_value(
|
||||
"Payroll Period", self.filters.payroll_period, ["start_date", "end_date"]
|
||||
)
|
||||
|
||||
def run(self):
|
||||
self.get_fixed_columns()
|
||||
self.get_data()
|
||||
return self.columns, self.data
|
||||
|
||||
def get_data(self):
|
||||
self.get_employee_details()
|
||||
self.get_future_salary_slips()
|
||||
self.get_ctc()
|
||||
self.get_tax_exempted_earnings_and_deductions()
|
||||
self.get_employee_tax_exemptions()
|
||||
self.get_hra()
|
||||
self.get_standard_tax_exemption()
|
||||
self.get_total_taxable_amount()
|
||||
self.get_applicable_tax()
|
||||
self.get_total_deducted_tax()
|
||||
self.get_payable_tax()
|
||||
|
||||
self.data = list(self.employees.values())
|
||||
|
||||
def get_employee_details(self):
|
||||
filters, or_filters = self.get_employee_filters()
|
||||
fields = [
|
||||
"name as employee",
|
||||
"employee_name",
|
||||
"department",
|
||||
"designation",
|
||||
"date_of_joining",
|
||||
"relieving_date",
|
||||
]
|
||||
|
||||
employees = frappe.get_all("Employee", filters=filters, or_filters=or_filters, fields=fields)
|
||||
ss_assignments = self.get_ss_assignments([d.employee for d in employees])
|
||||
|
||||
for d in employees:
|
||||
if d.employee in list(ss_assignments.keys()):
|
||||
d.update(ss_assignments[d.employee])
|
||||
self.employees.setdefault(d.employee, d)
|
||||
|
||||
if not self.employees:
|
||||
frappe.throw(_("No employees found with selected filters and active salary structure"))
|
||||
|
||||
def get_employee_filters(self):
|
||||
filters = {"company": self.filters.company}
|
||||
or_filters = {
|
||||
"status": "Active",
|
||||
"relieving_date": ["between", [self.payroll_period_start_date, self.payroll_period_end_date]],
|
||||
}
|
||||
if self.filters.employee:
|
||||
filters = {"name": self.filters.employee}
|
||||
elif self.filters.department:
|
||||
filters.update({"department": self.filters.department})
|
||||
|
||||
return filters, or_filters
|
||||
|
||||
def get_ss_assignments(self, employees):
|
||||
ss_assignments = frappe.get_all(
|
||||
"Salary Structure Assignment",
|
||||
filters={
|
||||
"employee": ["in", employees],
|
||||
"docstatus": 1,
|
||||
"salary_structure": ["is", "set"],
|
||||
"income_tax_slab": ["is", "set"],
|
||||
},
|
||||
fields=["employee", "income_tax_slab", "salary_structure"],
|
||||
order_by="from_date desc",
|
||||
)
|
||||
|
||||
employee_ss_assignments = frappe._dict()
|
||||
for d in ss_assignments:
|
||||
if d.employee not in list(employee_ss_assignments.keys()):
|
||||
tax_slab = frappe.get_cached_value(
|
||||
"Income Tax Slab", d.income_tax_slab, ["allow_tax_exemption", "disabled"], as_dict=1
|
||||
)
|
||||
|
||||
if tax_slab and not tax_slab.disabled:
|
||||
employee_ss_assignments.setdefault(
|
||||
d.employee,
|
||||
{
|
||||
"salary_structure": d.salary_structure,
|
||||
"income_tax_slab": d.income_tax_slab,
|
||||
"allow_tax_exemption": tax_slab.allow_tax_exemption,
|
||||
},
|
||||
)
|
||||
return employee_ss_assignments
|
||||
|
||||
def get_future_salary_slips(self):
|
||||
self.future_salary_slips = frappe._dict()
|
||||
for employee in list(self.employees.keys()):
|
||||
last_ss = self.get_last_salary_slip(employee)
|
||||
if last_ss and last_ss.end_date == self.payroll_period_end_date:
|
||||
continue
|
||||
|
||||
relieving_date = self.employees[employee].get("relieving_date", "")
|
||||
if last_ss:
|
||||
ss_start_date = add_days(last_ss.end_date, 1)
|
||||
else:
|
||||
ss_start_date = self.payroll_period_start_date
|
||||
last_ss = frappe._dict(
|
||||
{
|
||||
"payroll_frequency": "Monthly",
|
||||
"salary_structure": self.employees[employee].get("salary_structure"),
|
||||
}
|
||||
)
|
||||
|
||||
while getdate(ss_start_date) < getdate(self.payroll_period_end_date) and (
|
||||
not relieving_date or getdate(ss_start_date) < relieving_date
|
||||
):
|
||||
ss_end_date = get_start_end_dates(last_ss.payroll_frequency, ss_start_date).end_date
|
||||
|
||||
ss = frappe.new_doc("Salary Slip")
|
||||
ss.employee = employee
|
||||
ss.start_date = ss_start_date
|
||||
ss.end_date = ss_end_date
|
||||
ss.salary_structure = last_ss.salary_structure
|
||||
ss.payroll_frequency = last_ss.payroll_frequency
|
||||
ss.company = self.filters.company
|
||||
try:
|
||||
ss.process_salary_structure(for_preview=1)
|
||||
self.future_salary_slips.setdefault(employee, []).append(ss.as_dict())
|
||||
except Exception:
|
||||
break
|
||||
|
||||
ss_start_date = add_days(ss_end_date, 1)
|
||||
|
||||
def get_last_salary_slip(self, employee):
|
||||
last_salary_slip = frappe.db.get_value(
|
||||
"Salary Slip",
|
||||
{
|
||||
"employee": employee,
|
||||
"docstatus": 1,
|
||||
"start_date": ["between", [self.payroll_period_start_date, self.payroll_period_end_date]],
|
||||
},
|
||||
["start_date", "end_date", "salary_structure", "payroll_frequency"],
|
||||
order_by="start_date desc",
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
return last_salary_slip
|
||||
|
||||
def get_ctc(self):
|
||||
# Get total earnings from existing salary slip
|
||||
ss = frappe.qb.DocType("Salary Slip")
|
||||
existing_ss = frappe._dict(
|
||||
(
|
||||
frappe.qb.from_(ss)
|
||||
.select(ss.employee, Sum(ss.base_gross_pay).as_("amount"))
|
||||
.where(ss.docstatus == 1)
|
||||
.where(ss.employee.isin(list(self.employees.keys())))
|
||||
.where(ss.start_date >= self.payroll_period_start_date)
|
||||
.where(ss.end_date <= self.payroll_period_end_date)
|
||||
.groupby(ss.employee)
|
||||
).run()
|
||||
)
|
||||
|
||||
for employee in list(self.employees.keys()):
|
||||
future_ss_earnings = self.get_future_earnings(employee)
|
||||
ctc = flt(existing_ss.get(employee)) + future_ss_earnings
|
||||
|
||||
self.employees[employee].setdefault("ctc", ctc)
|
||||
|
||||
def get_future_earnings(self, employee):
|
||||
future_earnings = 0.0
|
||||
for ss in self.future_salary_slips.get(employee, []):
|
||||
future_earnings += flt(ss.base_gross_pay)
|
||||
|
||||
return future_earnings
|
||||
|
||||
def get_tax_exempted_earnings_and_deductions(self):
|
||||
tax_exempted_components = self.get_tax_exempted_components()
|
||||
|
||||
# Get component totals from existing salary slips
|
||||
ss = frappe.qb.DocType("Salary Slip")
|
||||
ss_comps = frappe.qb.DocType("Salary Detail")
|
||||
|
||||
records = (
|
||||
frappe.qb.from_(ss)
|
||||
.inner_join(ss_comps)
|
||||
.on(ss.name == ss_comps.parent)
|
||||
.select(ss.name, ss.employee, ss_comps.salary_component, Sum(ss_comps.amount).as_("amount"))
|
||||
.where(ss.docstatus == 1)
|
||||
.where(ss.employee.isin(list(self.employees.keys())))
|
||||
.where(ss_comps.salary_component.isin(tax_exempted_components))
|
||||
.where(ss.start_date >= self.payroll_period_start_date)
|
||||
.where(ss.end_date <= self.payroll_period_end_date)
|
||||
.groupby(ss.employee, ss_comps.salary_component)
|
||||
).run(as_dict=True)
|
||||
|
||||
existing_ss_exemptions = frappe._dict()
|
||||
for d in records:
|
||||
existing_ss_exemptions.setdefault(d.employee, {}).setdefault(
|
||||
scrub(d.salary_component), d.amount
|
||||
)
|
||||
|
||||
for employee in list(self.employees.keys()):
|
||||
if not self.employees[employee]["allow_tax_exemption"]:
|
||||
continue
|
||||
|
||||
exemptions = existing_ss_exemptions.get(employee, {})
|
||||
self.add_exemptions_from_future_salary_slips(employee, exemptions)
|
||||
self.employees[employee].update(exemptions)
|
||||
|
||||
total_exemptions = sum(list(exemptions.values()))
|
||||
self.add_to_total_exemption(employee, total_exemptions)
|
||||
|
||||
def add_exemptions_from_future_salary_slips(self, employee, exemptions):
|
||||
for ss in self.future_salary_slips.get(employee, []):
|
||||
for e in ss.earnings:
|
||||
if not e.is_tax_applicable:
|
||||
exemptions.setdefault(scrub(e.salary_component), 0)
|
||||
exemptions[scrub(e.salary_component)] += flt(e.amount)
|
||||
|
||||
for d in ss.deductions:
|
||||
if d.exempted_from_income_tax:
|
||||
exemptions.setdefault(scrub(d.salary_component), 0)
|
||||
exemptions[scrub(d.salary_component)] += flt(d.amount)
|
||||
|
||||
return exemptions
|
||||
|
||||
def get_tax_exempted_components(self):
|
||||
# nontaxable earning components
|
||||
nontaxable_earning_components = [
|
||||
d.name
|
||||
for d in frappe.get_all(
|
||||
"Salary Component", {"type": "Earning", "is_tax_applicable": 0, "disabled": 0}
|
||||
)
|
||||
]
|
||||
|
||||
# tax exempted deduction components
|
||||
tax_exempted_deduction_components = [
|
||||
d.name
|
||||
for d in frappe.get_all(
|
||||
"Salary Component", {"type": "Deduction", "exempted_from_income_tax": 1, "disabled": 0}
|
||||
)
|
||||
]
|
||||
|
||||
tax_exempted_components = nontaxable_earning_components + tax_exempted_deduction_components
|
||||
|
||||
# Add columns
|
||||
for d in tax_exempted_components:
|
||||
self.add_column(d)
|
||||
|
||||
return tax_exempted_components
|
||||
|
||||
def add_to_total_exemption(self, employee, amount):
|
||||
self.employees[employee].setdefault("total_exemption", 0)
|
||||
self.employees[employee]["total_exemption"] += amount
|
||||
|
||||
def get_employee_tax_exemptions(self):
|
||||
# add columns
|
||||
exemption_categories = frappe.get_all("Employee Tax Exemption Category", {"is_active": 1})
|
||||
for d in exemption_categories:
|
||||
self.add_column(d.name)
|
||||
|
||||
self.employees_with_proofs = []
|
||||
self.get_tax_exemptions("Employee Tax Exemption Proof Submission")
|
||||
if self.filters.consider_tax_exemption_declaration:
|
||||
self.get_tax_exemptions("Employee Tax Exemption Declaration")
|
||||
|
||||
def get_tax_exemptions(self, source):
|
||||
# Get category-wise exmeptions based on submitted proofs or declarations
|
||||
if source == "Employee Tax Exemption Proof Submission":
|
||||
child_doctype = "Employee Tax Exemption Proof Submission Detail"
|
||||
else:
|
||||
child_doctype = "Employee Tax Exemption Declaration Category"
|
||||
|
||||
max_exemptions = self.get_max_exemptions_based_on_category()
|
||||
|
||||
par = frappe.qb.DocType(source)
|
||||
child = frappe.qb.DocType(child_doctype)
|
||||
|
||||
records = (
|
||||
frappe.qb.from_(par)
|
||||
.inner_join(child)
|
||||
.on(par.name == child.parent)
|
||||
.select(par.employee, child.exemption_category, Sum(child.amount).as_("amount"))
|
||||
.where(par.docstatus == 1)
|
||||
.where(par.employee.isin(list(self.employees.keys())))
|
||||
.where(par.payroll_period == self.filters.payroll_period)
|
||||
.groupby(par.employee, child.exemption_category)
|
||||
).run(as_dict=True)
|
||||
|
||||
for d in records:
|
||||
if not self.employees[d.employee]["allow_tax_exemption"]:
|
||||
continue
|
||||
|
||||
if source == "Employee Tax Exemption Declaration" and d.employee in self.employees_with_proofs:
|
||||
continue
|
||||
|
||||
amount = flt(d.amount)
|
||||
max_eligible_amount = flt(max_exemptions.get(d.exemption_category))
|
||||
if max_eligible_amount and amount > max_eligible_amount:
|
||||
amount = max_eligible_amount
|
||||
|
||||
self.employees[d.employee].setdefault(scrub(d.exemption_category), amount)
|
||||
self.add_to_total_exemption(d.employee, amount)
|
||||
|
||||
if (
|
||||
source == "Employee Tax Exemption Proof Submission"
|
||||
and d.employee not in self.employees_with_proofs
|
||||
):
|
||||
self.employees_with_proofs.append(d.employee)
|
||||
|
||||
def get_max_exemptions_based_on_category(self):
|
||||
return dict(
|
||||
frappe.get_all(
|
||||
"Employee Tax Exemption Category",
|
||||
filters={"is_active": 1},
|
||||
fields=["name", "max_amount"],
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
|
||||
def get_hra(self):
|
||||
if not frappe.get_meta("Employee Tax Exemption Declaration").has_field("monthly_house_rent"):
|
||||
return
|
||||
|
||||
self.add_column("HRA")
|
||||
|
||||
self.employees_with_proofs = []
|
||||
self.get_eligible_hra("Employee Tax Exemption Proof Submission")
|
||||
if self.filters.consider_tax_exemption_declaration:
|
||||
self.get_eligible_hra("Employee Tax Exemption Declaration")
|
||||
|
||||
def get_eligible_hra(self, source):
|
||||
if source == "Employee Tax Exemption Proof Submission":
|
||||
hra_amount_field = "total_eligible_hra_exemption"
|
||||
else:
|
||||
hra_amount_field = "annual_hra_exemption"
|
||||
|
||||
records = frappe.get_all(
|
||||
source,
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"employee": ["in", list(self.employees.keys())],
|
||||
"payroll_period": self.filters.payroll_period,
|
||||
},
|
||||
fields=["employee", hra_amount_field],
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
for d in records:
|
||||
if not self.employees[d[0]]["allow_tax_exemption"]:
|
||||
continue
|
||||
|
||||
if d[0] not in self.employees_with_proofs:
|
||||
self.employees[d[0]].setdefault("hra", d[1])
|
||||
self.add_to_total_exemption(d[0], d[1])
|
||||
self.employees_with_proofs.append(d[0])
|
||||
|
||||
def get_standard_tax_exemption(self):
|
||||
self.add_column("Standard Tax Exemption")
|
||||
|
||||
standard_exemptions_per_slab = dict(
|
||||
frappe.get_all(
|
||||
"Income Tax Slab",
|
||||
filters={"company": self.filters.company, "docstatus": 1, "disabled": 0},
|
||||
fields=["name", "standard_tax_exemption_amount"],
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
|
||||
for emp, emp_details in self.employees.items():
|
||||
if not self.employees[emp]["allow_tax_exemption"]:
|
||||
continue
|
||||
|
||||
income_tax_slab = emp_details.get("income_tax_slab")
|
||||
standard_exemption = standard_exemptions_per_slab.get(income_tax_slab, 0)
|
||||
emp_details["standard_tax_exemption"] = standard_exemption
|
||||
self.add_to_total_exemption(emp, standard_exemption)
|
||||
|
||||
self.add_column("Total Exemption")
|
||||
|
||||
def get_total_taxable_amount(self):
|
||||
self.add_column("Total Taxable Amount")
|
||||
for emp, emp_details in self.employees.items():
|
||||
emp_details["total_taxable_amount"] = flt(emp_details.get("ctc")) - flt(
|
||||
emp_details.get("total_exemption")
|
||||
)
|
||||
|
||||
def get_applicable_tax(self):
|
||||
self.add_column("Applicable Tax")
|
||||
|
||||
is_tax_rounded = frappe.db.get_value(
|
||||
"Salary Component",
|
||||
{"variable_based_on_taxable_salary": 1, "disabled": 0},
|
||||
"round_to_the_nearest_integer",
|
||||
)
|
||||
|
||||
for emp, emp_details in self.employees.items():
|
||||
tax_slab = emp_details.get("income_tax_slab")
|
||||
if tax_slab:
|
||||
tax_slab = frappe.get_cached_doc("Income Tax Slab", tax_slab)
|
||||
employee_dict = frappe.get_doc("Employee", emp).as_dict()
|
||||
tax_amount = calculate_tax_by_tax_slab(
|
||||
emp_details["total_taxable_amount"], tax_slab, eval_globals=None, eval_locals=employee_dict
|
||||
)
|
||||
else:
|
||||
tax_amount = 0.0
|
||||
|
||||
if is_tax_rounded:
|
||||
tax_amount = rounded(tax_amount)
|
||||
emp_details["applicable_tax"] = tax_amount
|
||||
|
||||
def get_total_deducted_tax(self):
|
||||
self.add_column("Total Tax Deducted")
|
||||
|
||||
ss = frappe.qb.DocType("Salary Slip")
|
||||
ss_ded = frappe.qb.DocType("Salary Detail")
|
||||
|
||||
records = (
|
||||
frappe.qb.from_(ss)
|
||||
.inner_join(ss_ded)
|
||||
.on(ss.name == ss_ded.parent)
|
||||
.select(ss.employee, Sum(ss_ded.amount).as_("amount"))
|
||||
.where(ss.docstatus == 1)
|
||||
.where(ss.employee.isin(list(self.employees.keys())))
|
||||
.where(ss_ded.parentfield == "deductions")
|
||||
.where(ss_ded.variable_based_on_taxable_salary == 1)
|
||||
.where(ss.start_date >= self.payroll_period_start_date)
|
||||
.where(ss.end_date <= self.payroll_period_end_date)
|
||||
.groupby(ss.employee)
|
||||
).run(as_dict=True)
|
||||
|
||||
for d in records:
|
||||
self.employees[d.employee].setdefault("total_tax_deducted", d.amount)
|
||||
|
||||
def get_payable_tax(self):
|
||||
self.add_column("Payable Tax")
|
||||
|
||||
for emp, emp_details in self.employees.items():
|
||||
emp_details["payable_tax"] = flt(emp_details.get("applicable_tax")) - flt(
|
||||
emp_details.get("total_tax_deducted")
|
||||
)
|
||||
|
||||
def add_column(self, label, fieldname=None, fieldtype=None, options=None, width=None):
|
||||
col = {
|
||||
"label": _(label),
|
||||
"fieldname": fieldname or scrub(label),
|
||||
"fieldtype": fieldtype or "Currency",
|
||||
"options": options,
|
||||
"width": width or "140px",
|
||||
}
|
||||
self.columns.append(col)
|
||||
|
||||
def get_fixed_columns(self):
|
||||
self.columns = [
|
||||
{
|
||||
"label": _("Employee"),
|
||||
"fieldname": "employee",
|
||||
"fieldtype": "Link",
|
||||
"options": "Employee",
|
||||
"width": "140px",
|
||||
},
|
||||
{
|
||||
"label": _("Employee Name"),
|
||||
"fieldname": "employee_name",
|
||||
"fieldtype": "Data",
|
||||
"width": "160px",
|
||||
},
|
||||
{
|
||||
"label": _("Department"),
|
||||
"fieldname": "department",
|
||||
"fieldtype": "Link",
|
||||
"options": "Department",
|
||||
"width": "140px",
|
||||
},
|
||||
{
|
||||
"label": _("Designation"),
|
||||
"fieldname": "designation",
|
||||
"fieldtype": "Link",
|
||||
"options": "Designation",
|
||||
"width": "140px",
|
||||
},
|
||||
{"label": _("Date of Joining"), "fieldname": "date_of_joining", "fieldtype": "Date"},
|
||||
{
|
||||
"label": _("Income Tax Slab"),
|
||||
"fieldname": "income_tax_slab",
|
||||
"fieldtype": "Link",
|
||||
"options": "Income Tax Slab",
|
||||
"width": "140px",
|
||||
},
|
||||
{"label": _("CTC"), "fieldname": "ctc", "fieldtype": "Currency", "width": "140px"},
|
||||
]
|
@ -0,0 +1,115 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
|
||||
create_payroll_period,
|
||||
)
|
||||
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
|
||||
create_exemption_declaration,
|
||||
create_salary_slips_for_payroll_period,
|
||||
create_tax_slab,
|
||||
)
|
||||
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
|
||||
from erpnext.payroll.report.income_tax_computation.income_tax_computation import execute
|
||||
|
||||
|
||||
class TestIncomeTaxComputation(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cleanup_records()
|
||||
self.create_records()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def cleanup_records(self):
|
||||
frappe.db.sql("delete from `tabEmployee Tax Exemption Declaration`")
|
||||
frappe.db.sql("delete from `tabPayroll Period`")
|
||||
frappe.db.sql("delete from `tabIncome Tax Slab`")
|
||||
frappe.db.sql("delete from `tabSalary Component`")
|
||||
frappe.db.sql("delete from `tabEmployee Benefit Application`")
|
||||
frappe.db.sql("delete from `tabEmployee Benefit Claim`")
|
||||
frappe.db.sql("delete from `tabEmployee` where company='_Test Company'")
|
||||
frappe.db.sql("delete from `tabSalary Slip`")
|
||||
|
||||
def create_records(self):
|
||||
self.employee = make_employee(
|
||||
"employee_tax_computation@example.com",
|
||||
company="_Test Company",
|
||||
date_of_joining=getdate("01-10-2021"),
|
||||
)
|
||||
|
||||
self.payroll_period = create_payroll_period(
|
||||
name="_Test Payroll Period 1", company="_Test Company"
|
||||
)
|
||||
|
||||
self.income_tax_slab = create_tax_slab(
|
||||
self.payroll_period,
|
||||
allow_tax_exemption=True,
|
||||
effective_date=getdate("2019-04-01"),
|
||||
company="_Test Company",
|
||||
)
|
||||
salary_structure = make_salary_structure(
|
||||
"Monthly Salary Structure Test Income Tax Computation",
|
||||
"Monthly",
|
||||
employee=self.employee,
|
||||
company="_Test Company",
|
||||
currency="INR",
|
||||
payroll_period=self.payroll_period,
|
||||
test_tax=True,
|
||||
)
|
||||
|
||||
create_exemption_declaration(self.employee, self.payroll_period.name)
|
||||
|
||||
create_salary_slips_for_payroll_period(
|
||||
self.employee, salary_structure.name, self.payroll_period, deduct_random=False, num=3
|
||||
)
|
||||
|
||||
def test_report(self):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"payroll_period": self.payroll_period.name,
|
||||
"employee": self.employee,
|
||||
}
|
||||
)
|
||||
|
||||
result = execute(filters)
|
||||
|
||||
expected_data = {
|
||||
"employee": self.employee,
|
||||
"employee_name": "employee_tax_computation@example.com",
|
||||
"department": "All Departments",
|
||||
"income_tax_slab": self.income_tax_slab,
|
||||
"ctc": 936000.0,
|
||||
"professional_tax": 2400.0,
|
||||
"standard_tax_exemption": 50000,
|
||||
"total_exemption": 52400.0,
|
||||
"total_taxable_amount": 883600.0,
|
||||
"applicable_tax": 92789.0,
|
||||
"total_tax_deducted": 17997.0,
|
||||
"payable_tax": 74792,
|
||||
}
|
||||
|
||||
for key, val in expected_data.items():
|
||||
self.assertEqual(result[1][0].get(key), val)
|
||||
|
||||
# Run report considering tax exemption declaration
|
||||
filters.consider_tax_exemption_declaration = 1
|
||||
|
||||
result = execute(filters)
|
||||
|
||||
expected_data.update(
|
||||
{
|
||||
"_test_category": 100000.0,
|
||||
"total_exemption": 152400.0,
|
||||
"total_taxable_amount": 783600.0,
|
||||
"applicable_tax": 71989.0,
|
||||
"payable_tax": 53992.0,
|
||||
}
|
||||
)
|
||||
|
||||
for key, val in expected_data.items():
|
||||
self.assertEqual(result[1][0].get(key), val)
|
@ -245,6 +245,17 @@
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "Salary Structure",
|
||||
"hidden": 0,
|
||||
"is_query_report": 1,
|
||||
"label": "Income Tax Computation",
|
||||
"link_count": 0,
|
||||
"link_to": "Income Tax Computation",
|
||||
"link_type": "Report",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "Salary Slip",
|
||||
"hidden": 0,
|
||||
@ -312,7 +323,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2022-01-13 17:41:19.098813",
|
||||
"modified": "2022-02-23 17:41:19.098813",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Payroll",
|
||||
"name": "Payroll",
|
||||
|
@ -1,76 +1,31 @@
|
||||
{
|
||||
"allow_copy": 0,
|
||||
"allow_events_in_timeline": 0,
|
||||
"allow_guest_to_view": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"beta": 0,
|
||||
"creation": "2018-12-31 17:06:08.716134",
|
||||
"custom": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "DocType",
|
||||
"document_type": "",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"actions": [],
|
||||
"creation": "2018-12-31 17:06:08.716134",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"fieldname"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_in_quick_entry": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Fieldname",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Autocomplete",
|
||||
"in_list_view": 1,
|
||||
"label": "Fieldname"
|
||||
}
|
||||
],
|
||||
"has_web_view": 0,
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 0,
|
||||
"idx": 0,
|
||||
"image_view": 0,
|
||||
"in_create": 0,
|
||||
"is_submittable": 0,
|
||||
"issingle": 0,
|
||||
"istable": 1,
|
||||
"max_attachments": 0,
|
||||
"modified": "2019-01-01 18:26:11.550380",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Portal",
|
||||
"name": "Website Filter Field",
|
||||
"name_case": "",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0,
|
||||
"show_name_in_global_search": 0,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1,
|
||||
"track_seen": 0,
|
||||
"track_views": 0
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-04-18 18:55:17.835666",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Portal",
|
||||
"name": "Website Filter Field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
@ -326,21 +326,39 @@ def get_timeline_data(doctype, name):
|
||||
def get_project_list(
|
||||
doctype, txt, filters, limit_start, limit_page_length=20, order_by="modified"
|
||||
):
|
||||
return frappe.db.sql(
|
||||
"""select distinct project.*
|
||||
from tabProject project, `tabProject User` project_user
|
||||
where
|
||||
(project_user.user = %(user)s
|
||||
and project_user.parent = project.name)
|
||||
or project.owner = %(user)s
|
||||
order by project.modified desc
|
||||
limit {0}, {1}
|
||||
""".format(
|
||||
limit_start, limit_page_length
|
||||
),
|
||||
{"user": frappe.session.user},
|
||||
as_dict=True,
|
||||
update={"doctype": "Project"},
|
||||
meta = frappe.get_meta(doctype)
|
||||
if not filters:
|
||||
filters = []
|
||||
|
||||
fields = "distinct *"
|
||||
|
||||
or_filters = []
|
||||
|
||||
if txt:
|
||||
if meta.search_fields:
|
||||
for f in meta.get_search_fields():
|
||||
if f == "name" or meta.get_field(f).fieldtype in (
|
||||
"Data",
|
||||
"Text",
|
||||
"Small Text",
|
||||
"Text Editor",
|
||||
"select",
|
||||
):
|
||||
or_filters.append([doctype, f, "like", "%" + txt + "%"])
|
||||
else:
|
||||
if isinstance(filters, dict):
|
||||
filters["name"] = ("like", "%" + txt + "%")
|
||||
else:
|
||||
filters.append([doctype, "name", "like", "%" + txt + "%"])
|
||||
|
||||
return frappe.get_list(
|
||||
doctype,
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
or_filters=or_filters,
|
||||
limit_start=limit_start,
|
||||
limit_page_length=limit_page_length,
|
||||
order_by=order_by,
|
||||
)
|
||||
|
||||
|
||||
|
@ -140,6 +140,14 @@ class CallPopup {
|
||||
}, {
|
||||
'fieldtype': 'Section Break',
|
||||
'hide_border': 1,
|
||||
}, {
|
||||
'fieldname': 'call_type',
|
||||
'label': 'Call Type',
|
||||
'fieldtype': 'Link',
|
||||
'options': 'Telephony Call Type',
|
||||
}, {
|
||||
'fieldtype': 'Section Break',
|
||||
'hide_border': 1,
|
||||
}, {
|
||||
'fieldtype': 'Small Text',
|
||||
'label': __('Call Summary'),
|
||||
@ -149,10 +157,12 @@ class CallPopup {
|
||||
'label': __('Save'),
|
||||
'click': () => {
|
||||
const call_summary = this.call_details.get_value('call_summary');
|
||||
const call_type = this.call_details.get_value('call_type');
|
||||
if (!call_summary) return;
|
||||
frappe.xcall('erpnext.telephony.doctype.call_log.call_log.add_call_summary', {
|
||||
frappe.xcall('erpnext.telephony.doctype.call_log.call_log.add_call_summary_and_call_type', {
|
||||
'call_log': this.call_log.name,
|
||||
'summary': call_summary,
|
||||
'call_type': call_type,
|
||||
}).then(() => {
|
||||
this.close_modal();
|
||||
frappe.show_alert({
|
||||
|
@ -34,12 +34,12 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
frappe.model.set_value(item.doctype, item.name, "rate", item_rate);
|
||||
}
|
||||
|
||||
calculate_taxes_and_totals(update_paid_amount) {
|
||||
async calculate_taxes_and_totals(update_paid_amount) {
|
||||
this.discount_amount_applied = false;
|
||||
this._calculate_taxes_and_totals();
|
||||
this.calculate_discount_amount();
|
||||
|
||||
this.calculate_shipping_charges();
|
||||
await this.calculate_shipping_charges();
|
||||
|
||||
// Advance calculation applicable to Sales /Purchase Invoice
|
||||
if(in_list(["Sales Invoice", "POS Invoice", "Purchase Invoice"], this.frm.doc.doctype)
|
||||
@ -273,10 +273,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
}
|
||||
|
||||
calculate_shipping_charges() {
|
||||
// Do not apply shipping rule for POS
|
||||
if (this.frm.doc.is_pos) {
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.model.round_floats_in(this.frm.doc, ["total", "base_total", "net_total", "base_net_total"]);
|
||||
if (frappe.meta.get_docfield(this.frm.doc.doctype, "shipping_rule", this.frm.doc.name)) {
|
||||
this.shipping_rule();
|
||||
this._calculate_taxes_and_totals();
|
||||
return this.shipping_rule();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -974,6 +974,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
return this.frm.call({
|
||||
doc: this.frm.doc,
|
||||
method: "apply_shipping_rule",
|
||||
callback: function(r) {
|
||||
me._calculate_taxes_and_totals();
|
||||
}
|
||||
}).fail(() => this.frm.set_value('shipping_rule', ''));
|
||||
}
|
||||
}
|
||||
@ -1385,6 +1388,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
return;
|
||||
}
|
||||
|
||||
// Target doc created from a mapped doc
|
||||
if (this.frm.doc.__onload && this.frm.doc.__onload.ignore_price_list) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.frm.call({
|
||||
method: "erpnext.accounts.doctype.pricing_rule.pricing_rule.apply_pricing_rule",
|
||||
args: { args: args, doc: me.frm.doc },
|
||||
@ -1501,7 +1509,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
me.remove_pricing_rule(frappe.get_doc(d.doctype, d.name));
|
||||
}
|
||||
|
||||
if (d.free_item_data) {
|
||||
if (d.free_item_data.length > 0) {
|
||||
me.apply_product_discount(d);
|
||||
}
|
||||
|
||||
|
@ -68,7 +68,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
row = this.get_batch_row_to_modify(batch_no);
|
||||
} else {
|
||||
// serial or barcode scan
|
||||
row = this.get_row_to_modify_on_scan(row, item_code);
|
||||
row = this.get_row_to_modify_on_scan(item_code);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
@ -177,21 +177,17 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
get_batch_row_to_modify(batch_no) {
|
||||
// get row if batch already exists in table
|
||||
const existing_batch_row = this.items_table.find((d) => d.batch_no === batch_no);
|
||||
return existing_batch_row || null;
|
||||
return existing_batch_row || this.get_existing_blank_row();
|
||||
}
|
||||
|
||||
get_row_to_modify_on_scan(row_to_modify, item_code) {
|
||||
get_row_to_modify_on_scan(item_code) {
|
||||
// get an existing item row to increment or blank row to modify
|
||||
const existing_item_row = this.items_table.find((d) => d.item_code === item_code);
|
||||
const blank_item_row = this.items_table.find((d) => !d.item_code);
|
||||
return existing_item_row || this.get_existing_blank_row();
|
||||
}
|
||||
|
||||
if (existing_item_row) {
|
||||
row_to_modify = existing_item_row;
|
||||
} else if (blank_item_row) {
|
||||
row_to_modify = blank_item_row;
|
||||
}
|
||||
|
||||
return row_to_modify;
|
||||
get_existing_blank_row() {
|
||||
return this.items_table.find((d) => !d.item_code);
|
||||
}
|
||||
|
||||
clean_up() {
|
||||
|
@ -19,7 +19,7 @@ class TestQualityProcedure(unittest.TestCase):
|
||||
)
|
||||
).insert()
|
||||
|
||||
frappe.form_dict = dict(
|
||||
frappe.local.form_dict = frappe._dict(
|
||||
doctype="Quality Procedure",
|
||||
quality_procedure_name="Test Child 1",
|
||||
parent_quality_procedure=procedure.name,
|
||||
|
@ -1,8 +0,0 @@
|
||||
// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on('DATEV Settings', {
|
||||
refresh: function(frm) {
|
||||
frm.add_custom_button(__('Show Report'), () => frappe.set_route('query-report', 'DATEV'), "fa fa-table");
|
||||
}
|
||||
});
|
@ -1,125 +0,0 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "field:client",
|
||||
"creation": "2019-08-13 23:56:34.259906",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"client",
|
||||
"client_number",
|
||||
"column_break_2",
|
||||
"consultant_number",
|
||||
"consultant",
|
||||
"section_break_4",
|
||||
"account_number_length",
|
||||
"column_break_6",
|
||||
"temporary_against_account_number"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "client",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Client",
|
||||
"options": "Company",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "client_number",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Client ID",
|
||||
"length": 5,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "consultant",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Consultant",
|
||||
"options": "Supplier"
|
||||
},
|
||||
{
|
||||
"fieldname": "consultant_number",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Consultant ID",
|
||||
"length": 7,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_4",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_6",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "4",
|
||||
"fieldname": "account_number_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Account Number Length",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"fieldname": "temporary_against_account_number",
|
||||
"fieldtype": "Data",
|
||||
"label": "Temporary Against Account Number",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2020-11-19 19:00:09.088816",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Regional",
|
||||
"name": "DATEV Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
|
||||
class TestDATEVSettings(unittest.TestCase):
|
||||
pass
|
@ -1,35 +0,0 @@
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
|
||||
def setup(company=None, patch=True):
|
||||
make_custom_fields()
|
||||
add_custom_roles_for_reports()
|
||||
|
||||
|
||||
def make_custom_fields():
|
||||
custom_fields = {
|
||||
"Party Account": [
|
||||
dict(
|
||||
fieldname="debtor_creditor_number",
|
||||
label="Debtor/Creditor Number",
|
||||
fieldtype="Data",
|
||||
insert_after="account",
|
||||
translatable=0,
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
create_custom_fields(custom_fields)
|
||||
|
||||
|
||||
def add_custom_roles_for_reports():
|
||||
"""Add Access Control to UAE VAT 201."""
|
||||
if not frappe.db.get_value("Custom Role", dict(report="DATEV")):
|
||||
frappe.get_doc(
|
||||
dict(
|
||||
doctype="Custom Role",
|
||||
report="DATEV",
|
||||
roles=[dict(role="Accounts User"), dict(role="Accounts Manager")],
|
||||
)
|
||||
).insert()
|
@ -1,501 +0,0 @@
|
||||
"""Constants used in datev.py."""
|
||||
|
||||
TRANSACTION_COLUMNS = [
|
||||
# All possible columns must tbe listed here, because DATEV requires them to
|
||||
# be present in the CSV.
|
||||
# ---
|
||||
# Umsatz
|
||||
"Umsatz (ohne Soll/Haben-Kz)",
|
||||
"Soll/Haben-Kennzeichen",
|
||||
"WKZ Umsatz",
|
||||
"Kurs",
|
||||
"Basis-Umsatz",
|
||||
"WKZ Basis-Umsatz",
|
||||
# Konto/Gegenkonto
|
||||
"Konto",
|
||||
"Gegenkonto (ohne BU-Schlüssel)",
|
||||
"BU-Schlüssel",
|
||||
# Datum
|
||||
"Belegdatum",
|
||||
# Rechnungs- / Belegnummer
|
||||
"Belegfeld 1",
|
||||
# z.B. Fälligkeitsdatum Format: TTMMJJ
|
||||
"Belegfeld 2",
|
||||
# Skonto-Betrag / -Abzug (Der Wert 0 ist unzulässig)
|
||||
"Skonto",
|
||||
# Beschreibung des Buchungssatzes
|
||||
"Buchungstext",
|
||||
# Mahn- / Zahl-Sperre (1 = Postensperre)
|
||||
"Postensperre",
|
||||
"Diverse Adressnummer",
|
||||
"Geschäftspartnerbank",
|
||||
"Sachverhalt",
|
||||
# Keine Mahnzinsen
|
||||
"Zinssperre",
|
||||
# Link auf den Buchungsbeleg (Programmkürzel + GUID)
|
||||
"Beleglink",
|
||||
# Beleginfo
|
||||
"Beleginfo - Art 1",
|
||||
"Beleginfo - Inhalt 1",
|
||||
"Beleginfo - Art 2",
|
||||
"Beleginfo - Inhalt 2",
|
||||
"Beleginfo - Art 3",
|
||||
"Beleginfo - Inhalt 3",
|
||||
"Beleginfo - Art 4",
|
||||
"Beleginfo - Inhalt 4",
|
||||
"Beleginfo - Art 5",
|
||||
"Beleginfo - Inhalt 5",
|
||||
"Beleginfo - Art 6",
|
||||
"Beleginfo - Inhalt 6",
|
||||
"Beleginfo - Art 7",
|
||||
"Beleginfo - Inhalt 7",
|
||||
"Beleginfo - Art 8",
|
||||
"Beleginfo - Inhalt 8",
|
||||
# Zuordnung des Geschäftsvorfalls für die Kostenrechnung
|
||||
"KOST1 - Kostenstelle",
|
||||
"KOST2 - Kostenstelle",
|
||||
"KOST-Menge",
|
||||
# USt-ID-Nummer (Beispiel: DE133546770)
|
||||
"EU-Mitgliedstaat u. USt-IdNr.",
|
||||
# Der im EU-Bestimmungsland gültige Steuersatz
|
||||
"EU-Steuersatz",
|
||||
# I = Ist-Versteuerung,
|
||||
# K = keine Umsatzsteuerrechnung
|
||||
# P = Pauschalierung (z. B. für Land- und Forstwirtschaft),
|
||||
# S = Soll-Versteuerung
|
||||
"Abw. Versteuerungsart",
|
||||
# Sachverhalte gem. § 13b Abs. 1 Satz 1 Nrn. 1.-5. UStG
|
||||
"Sachverhalt L+L",
|
||||
# Steuersatz / Funktion zum L+L-Sachverhalt (Beispiel: Wert 190 für 19%)
|
||||
"Funktionsergänzung L+L",
|
||||
# Bei Verwendung des BU-Schlüssels 49 für „andere Steuersätze“ muss der
|
||||
# steuerliche Sachverhalt mitgegeben werden
|
||||
"BU 49 Hauptfunktionstyp",
|
||||
"BU 49 Hauptfunktionsnummer",
|
||||
"BU 49 Funktionsergänzung",
|
||||
# Zusatzinformationen, besitzen den Charakter eines Notizzettels und können
|
||||
# frei erfasst werden.
|
||||
"Zusatzinformation - Art 1",
|
||||
"Zusatzinformation - Inhalt 1",
|
||||
"Zusatzinformation - Art 2",
|
||||
"Zusatzinformation - Inhalt 2",
|
||||
"Zusatzinformation - Art 3",
|
||||
"Zusatzinformation - Inhalt 3",
|
||||
"Zusatzinformation - Art 4",
|
||||
"Zusatzinformation - Inhalt 4",
|
||||
"Zusatzinformation - Art 5",
|
||||
"Zusatzinformation - Inhalt 5",
|
||||
"Zusatzinformation - Art 6",
|
||||
"Zusatzinformation - Inhalt 6",
|
||||
"Zusatzinformation - Art 7",
|
||||
"Zusatzinformation - Inhalt 7",
|
||||
"Zusatzinformation - Art 8",
|
||||
"Zusatzinformation - Inhalt 8",
|
||||
"Zusatzinformation - Art 9",
|
||||
"Zusatzinformation - Inhalt 9",
|
||||
"Zusatzinformation - Art 10",
|
||||
"Zusatzinformation - Inhalt 10",
|
||||
"Zusatzinformation - Art 11",
|
||||
"Zusatzinformation - Inhalt 11",
|
||||
"Zusatzinformation - Art 12",
|
||||
"Zusatzinformation - Inhalt 12",
|
||||
"Zusatzinformation - Art 13",
|
||||
"Zusatzinformation - Inhalt 13",
|
||||
"Zusatzinformation - Art 14",
|
||||
"Zusatzinformation - Inhalt 14",
|
||||
"Zusatzinformation - Art 15",
|
||||
"Zusatzinformation - Inhalt 15",
|
||||
"Zusatzinformation - Art 16",
|
||||
"Zusatzinformation - Inhalt 16",
|
||||
"Zusatzinformation - Art 17",
|
||||
"Zusatzinformation - Inhalt 17",
|
||||
"Zusatzinformation - Art 18",
|
||||
"Zusatzinformation - Inhalt 18",
|
||||
"Zusatzinformation - Art 19",
|
||||
"Zusatzinformation - Inhalt 19",
|
||||
"Zusatzinformation - Art 20",
|
||||
"Zusatzinformation - Inhalt 20",
|
||||
# Wirkt sich nur bei Sachverhalt mit SKR 14 Land- und Forstwirtschaft aus,
|
||||
# für andere SKR werden die Felder beim Import / Export überlesen bzw.
|
||||
# leer exportiert.
|
||||
"Stück",
|
||||
"Gewicht",
|
||||
# 1 = Lastschrift
|
||||
# 2 = Mahnung
|
||||
# 3 = Zahlung
|
||||
"Zahlweise",
|
||||
"Forderungsart",
|
||||
# JJJJ
|
||||
"Veranlagungsjahr",
|
||||
# TTMMJJJJ
|
||||
"Zugeordnete Fälligkeit",
|
||||
# 1 = Einkauf von Waren
|
||||
# 2 = Erwerb von Roh-Hilfs- und Betriebsstoffen
|
||||
"Skontotyp",
|
||||
# Allgemeine Bezeichnung, des Auftrags / Projekts.
|
||||
"Auftragsnummer",
|
||||
# AA = Angeforderte Anzahlung / Abschlagsrechnung
|
||||
# AG = Erhaltene Anzahlung (Geldeingang)
|
||||
# AV = Erhaltene Anzahlung (Verbindlichkeit)
|
||||
# SR = Schlussrechnung
|
||||
# SU = Schlussrechnung (Umbuchung)
|
||||
# SG = Schlussrechnung (Geldeingang)
|
||||
# SO = Sonstige
|
||||
"Buchungstyp",
|
||||
"USt-Schlüssel (Anzahlungen)",
|
||||
"EU-Mitgliedstaat (Anzahlungen)",
|
||||
"Sachverhalt L+L (Anzahlungen)",
|
||||
"EU-Steuersatz (Anzahlungen)",
|
||||
"Erlöskonto (Anzahlungen)",
|
||||
# Wird beim Import durch SV (Stapelverarbeitung) ersetzt.
|
||||
"Herkunft-Kz",
|
||||
# Wird von DATEV verwendet.
|
||||
"Leerfeld",
|
||||
# Format TTMMJJJJ
|
||||
"KOST-Datum",
|
||||
# Vom Zahlungsempfänger individuell vergebenes Kennzeichen eines Mandats
|
||||
# (z.B. Rechnungs- oder Kundennummer).
|
||||
"SEPA-Mandatsreferenz",
|
||||
# 1 = Skontosperre
|
||||
# 0 = Keine Skontosperre
|
||||
"Skontosperre",
|
||||
# Gesellschafter und Sonderbilanzsachverhalt
|
||||
"Gesellschaftername",
|
||||
# Amtliche Nummer aus der Feststellungserklärung
|
||||
"Beteiligtennummer",
|
||||
"Identifikationsnummer",
|
||||
"Zeichnernummer",
|
||||
# Format TTMMJJJJ
|
||||
"Postensperre bis",
|
||||
# Gesellschafter und Sonderbilanzsachverhalt
|
||||
"Bezeichnung SoBil-Sachverhalt",
|
||||
"Kennzeichen SoBil-Buchung",
|
||||
# 0 = keine Festschreibung
|
||||
# 1 = Festschreibung
|
||||
"Festschreibung",
|
||||
# Format TTMMJJJJ
|
||||
"Leistungsdatum",
|
||||
# Format TTMMJJJJ
|
||||
"Datum Zuord. Steuerperiode",
|
||||
# OPOS-Informationen, Format TTMMJJJJ
|
||||
"Fälligkeit",
|
||||
# G oder 1 = Generalumkehr
|
||||
# 0 = keine Generalumkehr
|
||||
"Generalumkehr (GU)",
|
||||
# Steuersatz für Steuerschlüssel
|
||||
"Steuersatz",
|
||||
# Beispiel: DE für Deutschland
|
||||
"Land",
|
||||
]
|
||||
|
||||
DEBTOR_CREDITOR_COLUMNS = [
|
||||
# All possible columns must tbe listed here, because DATEV requires them to
|
||||
# be present in the CSV.
|
||||
# Columns "Leerfeld" have been replaced with "Leerfeld #" to not confuse pandas
|
||||
# ---
|
||||
"Konto",
|
||||
"Name (Adressatentyp Unternehmen)",
|
||||
"Unternehmensgegenstand",
|
||||
"Name (Adressatentyp natürl. Person)",
|
||||
"Vorname (Adressatentyp natürl. Person)",
|
||||
"Name (Adressatentyp keine Angabe)",
|
||||
"Adressatentyp",
|
||||
"Kurzbezeichnung",
|
||||
"EU-Land",
|
||||
"EU-USt-IdNr.",
|
||||
"Anrede",
|
||||
"Titel/Akad. Grad",
|
||||
"Adelstitel",
|
||||
"Namensvorsatz",
|
||||
"Adressart",
|
||||
"Straße",
|
||||
"Postfach",
|
||||
"Postleitzahl",
|
||||
"Ort",
|
||||
"Land",
|
||||
"Versandzusatz",
|
||||
"Adresszusatz",
|
||||
"Abweichende Anrede",
|
||||
"Abw. Zustellbezeichnung 1",
|
||||
"Abw. Zustellbezeichnung 2",
|
||||
"Kennz. Korrespondenzadresse",
|
||||
"Adresse gültig von",
|
||||
"Adresse gültig bis",
|
||||
"Telefon",
|
||||
"Bemerkung (Telefon)",
|
||||
"Telefon Geschäftsleitung",
|
||||
"Bemerkung (Telefon GL)",
|
||||
"E-Mail",
|
||||
"Bemerkung (E-Mail)",
|
||||
"Internet",
|
||||
"Bemerkung (Internet)",
|
||||
"Fax",
|
||||
"Bemerkung (Fax)",
|
||||
"Sonstige",
|
||||
"Bemerkung (Sonstige)",
|
||||
"Bankleitzahl 1",
|
||||
"Bankbezeichnung 1",
|
||||
"Bankkonto-Nummer 1",
|
||||
"Länderkennzeichen 1",
|
||||
"IBAN 1",
|
||||
"Leerfeld 1",
|
||||
"SWIFT-Code 1",
|
||||
"Abw. Kontoinhaber 1",
|
||||
"Kennz. Haupt-Bankverb. 1",
|
||||
"Bankverb. 1 Gültig von",
|
||||
"Bankverb. 1 Gültig bis",
|
||||
"Bankleitzahl 2",
|
||||
"Bankbezeichnung 2",
|
||||
"Bankkonto-Nummer 2",
|
||||
"Länderkennzeichen 2",
|
||||
"IBAN 2",
|
||||
"Leerfeld 2",
|
||||
"SWIFT-Code 2",
|
||||
"Abw. Kontoinhaber 2",
|
||||
"Kennz. Haupt-Bankverb. 2",
|
||||
"Bankverb. 2 gültig von",
|
||||
"Bankverb. 2 gültig bis",
|
||||
"Bankleitzahl 3",
|
||||
"Bankbezeichnung 3",
|
||||
"Bankkonto-Nummer 3",
|
||||
"Länderkennzeichen 3",
|
||||
"IBAN 3",
|
||||
"Leerfeld 3",
|
||||
"SWIFT-Code 3",
|
||||
"Abw. Kontoinhaber 3",
|
||||
"Kennz. Haupt-Bankverb. 3",
|
||||
"Bankverb. 3 gültig von",
|
||||
"Bankverb. 3 gültig bis",
|
||||
"Bankleitzahl 4",
|
||||
"Bankbezeichnung 4",
|
||||
"Bankkonto-Nummer 4",
|
||||
"Länderkennzeichen 4",
|
||||
"IBAN 4",
|
||||
"Leerfeld 4",
|
||||
"SWIFT-Code 4",
|
||||
"Abw. Kontoinhaber 4",
|
||||
"Kennz. Haupt-Bankverb. 4",
|
||||
"Bankverb. 4 Gültig von",
|
||||
"Bankverb. 4 Gültig bis",
|
||||
"Bankleitzahl 5",
|
||||
"Bankbezeichnung 5",
|
||||
"Bankkonto-Nummer 5",
|
||||
"Länderkennzeichen 5",
|
||||
"IBAN 5",
|
||||
"Leerfeld 5",
|
||||
"SWIFT-Code 5",
|
||||
"Abw. Kontoinhaber 5",
|
||||
"Kennz. Haupt-Bankverb. 5",
|
||||
"Bankverb. 5 gültig von",
|
||||
"Bankverb. 5 gültig bis",
|
||||
"Leerfeld 6",
|
||||
"Briefanrede",
|
||||
"Grußformel",
|
||||
"Kundennummer",
|
||||
"Steuernummer",
|
||||
"Sprache",
|
||||
"Ansprechpartner",
|
||||
"Vertreter",
|
||||
"Sachbearbeiter",
|
||||
"Diverse-Konto",
|
||||
"Ausgabeziel",
|
||||
"Währungssteuerung",
|
||||
"Kreditlimit (Debitor)",
|
||||
"Zahlungsbedingung",
|
||||
"Fälligkeit in Tagen (Debitor)",
|
||||
"Skonto in Prozent (Debitor)",
|
||||
"Kreditoren-Ziel 1 (Tage)",
|
||||
"Kreditoren-Skonto 1 (%)",
|
||||
"Kreditoren-Ziel 2 (Tage)",
|
||||
"Kreditoren-Skonto 2 (%)",
|
||||
"Kreditoren-Ziel 3 Brutto (Tage)",
|
||||
"Kreditoren-Ziel 4 (Tage)",
|
||||
"Kreditoren-Skonto 4 (%)",
|
||||
"Kreditoren-Ziel 5 (Tage)",
|
||||
"Kreditoren-Skonto 5 (%)",
|
||||
"Mahnung",
|
||||
"Kontoauszug",
|
||||
"Mahntext 1",
|
||||
"Mahntext 2",
|
||||
"Mahntext 3",
|
||||
"Kontoauszugstext",
|
||||
"Mahnlimit Betrag",
|
||||
"Mahnlimit %",
|
||||
"Zinsberechnung",
|
||||
"Mahnzinssatz 1",
|
||||
"Mahnzinssatz 2",
|
||||
"Mahnzinssatz 3",
|
||||
"Lastschrift",
|
||||
"Verfahren",
|
||||
"Mandantenbank",
|
||||
"Zahlungsträger",
|
||||
"Indiv. Feld 1",
|
||||
"Indiv. Feld 2",
|
||||
"Indiv. Feld 3",
|
||||
"Indiv. Feld 4",
|
||||
"Indiv. Feld 5",
|
||||
"Indiv. Feld 6",
|
||||
"Indiv. Feld 7",
|
||||
"Indiv. Feld 8",
|
||||
"Indiv. Feld 9",
|
||||
"Indiv. Feld 10",
|
||||
"Indiv. Feld 11",
|
||||
"Indiv. Feld 12",
|
||||
"Indiv. Feld 13",
|
||||
"Indiv. Feld 14",
|
||||
"Indiv. Feld 15",
|
||||
"Abweichende Anrede (Rechnungsadresse)",
|
||||
"Adressart (Rechnungsadresse)",
|
||||
"Straße (Rechnungsadresse)",
|
||||
"Postfach (Rechnungsadresse)",
|
||||
"Postleitzahl (Rechnungsadresse)",
|
||||
"Ort (Rechnungsadresse)",
|
||||
"Land (Rechnungsadresse)",
|
||||
"Versandzusatz (Rechnungsadresse)",
|
||||
"Adresszusatz (Rechnungsadresse)",
|
||||
"Abw. Zustellbezeichnung 1 (Rechnungsadresse)",
|
||||
"Abw. Zustellbezeichnung 2 (Rechnungsadresse)",
|
||||
"Adresse Gültig von (Rechnungsadresse)",
|
||||
"Adresse Gültig bis (Rechnungsadresse)",
|
||||
"Bankleitzahl 6",
|
||||
"Bankbezeichnung 6",
|
||||
"Bankkonto-Nummer 6",
|
||||
"Länderkennzeichen 6",
|
||||
"IBAN 6",
|
||||
"Leerfeld 7",
|
||||
"SWIFT-Code 6",
|
||||
"Abw. Kontoinhaber 6",
|
||||
"Kennz. Haupt-Bankverb. 6",
|
||||
"Bankverb 6 gültig von",
|
||||
"Bankverb 6 gültig bis",
|
||||
"Bankleitzahl 7",
|
||||
"Bankbezeichnung 7",
|
||||
"Bankkonto-Nummer 7",
|
||||
"Länderkennzeichen 7",
|
||||
"IBAN 7",
|
||||
"Leerfeld 8",
|
||||
"SWIFT-Code 7",
|
||||
"Abw. Kontoinhaber 7",
|
||||
"Kennz. Haupt-Bankverb. 7",
|
||||
"Bankverb 7 gültig von",
|
||||
"Bankverb 7 gültig bis",
|
||||
"Bankleitzahl 8",
|
||||
"Bankbezeichnung 8",
|
||||
"Bankkonto-Nummer 8",
|
||||
"Länderkennzeichen 8",
|
||||
"IBAN 8",
|
||||
"Leerfeld 9",
|
||||
"SWIFT-Code 8",
|
||||
"Abw. Kontoinhaber 8",
|
||||
"Kennz. Haupt-Bankverb. 8",
|
||||
"Bankverb 8 gültig von",
|
||||
"Bankverb 8 gültig bis",
|
||||
"Bankleitzahl 9",
|
||||
"Bankbezeichnung 9",
|
||||
"Bankkonto-Nummer 9",
|
||||
"Länderkennzeichen 9",
|
||||
"IBAN 9",
|
||||
"Leerfeld 10",
|
||||
"SWIFT-Code 9",
|
||||
"Abw. Kontoinhaber 9",
|
||||
"Kennz. Haupt-Bankverb. 9",
|
||||
"Bankverb 9 gültig von",
|
||||
"Bankverb 9 gültig bis",
|
||||
"Bankleitzahl 10",
|
||||
"Bankbezeichnung 10",
|
||||
"Bankkonto-Nummer 10",
|
||||
"Länderkennzeichen 10",
|
||||
"IBAN 10",
|
||||
"Leerfeld 11",
|
||||
"SWIFT-Code 10",
|
||||
"Abw. Kontoinhaber 10",
|
||||
"Kennz. Haupt-Bankverb. 10",
|
||||
"Bankverb 10 gültig von",
|
||||
"Bankverb 10 gültig bis",
|
||||
"Nummer Fremdsystem",
|
||||
"Insolvent",
|
||||
"SEPA-Mandatsreferenz 1",
|
||||
"SEPA-Mandatsreferenz 2",
|
||||
"SEPA-Mandatsreferenz 3",
|
||||
"SEPA-Mandatsreferenz 4",
|
||||
"SEPA-Mandatsreferenz 5",
|
||||
"SEPA-Mandatsreferenz 6",
|
||||
"SEPA-Mandatsreferenz 7",
|
||||
"SEPA-Mandatsreferenz 8",
|
||||
"SEPA-Mandatsreferenz 9",
|
||||
"SEPA-Mandatsreferenz 10",
|
||||
"Verknüpftes OPOS-Konto",
|
||||
"Mahnsperre bis",
|
||||
"Lastschriftsperre bis",
|
||||
"Zahlungssperre bis",
|
||||
"Gebührenberechnung",
|
||||
"Mahngebühr 1",
|
||||
"Mahngebühr 2",
|
||||
"Mahngebühr 3",
|
||||
"Pauschalberechnung",
|
||||
"Verzugspauschale 1",
|
||||
"Verzugspauschale 2",
|
||||
"Verzugspauschale 3",
|
||||
"Alternativer Suchname",
|
||||
"Status",
|
||||
"Anschrift manuell geändert (Korrespondenzadresse)",
|
||||
"Anschrift individuell (Korrespondenzadresse)",
|
||||
"Anschrift manuell geändert (Rechnungsadresse)",
|
||||
"Anschrift individuell (Rechnungsadresse)",
|
||||
"Fristberechnung bei Debitor",
|
||||
"Mahnfrist 1",
|
||||
"Mahnfrist 2",
|
||||
"Mahnfrist 3",
|
||||
"Letzte Frist",
|
||||
]
|
||||
|
||||
ACCOUNT_NAME_COLUMNS = [
|
||||
# Account number
|
||||
"Konto",
|
||||
# Account name
|
||||
"Kontenbeschriftung",
|
||||
# Language of the account name
|
||||
# "de-DE" or "en-GB"
|
||||
"Sprach-ID",
|
||||
]
|
||||
|
||||
|
||||
class DataCategory:
|
||||
|
||||
"""Field of the CSV Header."""
|
||||
|
||||
DEBTORS_CREDITORS = "16"
|
||||
ACCOUNT_NAMES = "20"
|
||||
TRANSACTIONS = "21"
|
||||
POSTING_TEXT_CONSTANTS = "67"
|
||||
|
||||
|
||||
class FormatName:
|
||||
|
||||
"""Field of the CSV Header, corresponds to DataCategory."""
|
||||
|
||||
DEBTORS_CREDITORS = "Debitoren/Kreditoren"
|
||||
ACCOUNT_NAMES = "Kontenbeschriftungen"
|
||||
TRANSACTIONS = "Buchungsstapel"
|
||||
POSTING_TEXT_CONSTANTS = "Buchungstextkonstanten"
|
||||
|
||||
|
||||
class Transactions:
|
||||
DATA_CATEGORY = DataCategory.TRANSACTIONS
|
||||
FORMAT_NAME = FormatName.TRANSACTIONS
|
||||
FORMAT_VERSION = "9"
|
||||
COLUMNS = TRANSACTION_COLUMNS
|
||||
|
||||
|
||||
class DebtorsCreditors:
|
||||
DATA_CATEGORY = DataCategory.DEBTORS_CREDITORS
|
||||
FORMAT_NAME = FormatName.DEBTORS_CREDITORS
|
||||
FORMAT_VERSION = "5"
|
||||
COLUMNS = DEBTOR_CREDITOR_COLUMNS
|
||||
|
||||
|
||||
class AccountNames:
|
||||
DATA_CATEGORY = DataCategory.ACCOUNT_NAMES
|
||||
FORMAT_NAME = FormatName.ACCOUNT_NAMES
|
||||
FORMAT_VERSION = "2"
|
||||
COLUMNS = ACCOUNT_NAME_COLUMNS
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user