diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a6e16a03d8..8f938112a7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -24,20 +24,6 @@ body: validations: required: true - - type: dropdown - id: version - attributes: - label: Version - description: Affected versions. - multiple: true - options: - - v12 - - v13 - - v14 - - develop - validations: - required: true - - type: dropdown id: module attributes: @@ -86,7 +72,7 @@ body: - manual install - FrappeCloud validations: - required: true + required: false - type: textarea id: logs @@ -95,12 +81,7 @@ body: description: Please copy and paste any relevant log output. This will be automatically formatted. render: shell - - - type: checkboxes - id: terms + - type: markdown attributes: - label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/frappe/erpnext/blob/develop/CODE_OF_CONDUCT.md) - options: - - label: I agree to follow this project's Code of Conduct - required: true + value: | + By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/frappe/erpnext/blob/develop/CODE_OF_CONDUCT.md) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 64712b550f..321b45323f 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1049,6 +1049,8 @@ class SalesInvoice(SellingController): frappe.flags.is_reverse_depr_entry = False asset.flags.ignore_validate_update_after_submit = True schedule.journal_entry = None + depreciation_amount = self.get_depreciation_amount_in_je(reverse_journal_entry) + asset.finance_books[0].value_after_depreciation += depreciation_amount asset.save() def get_posting_date_of_sales_invoice(self): @@ -1071,6 +1073,12 @@ class SalesInvoice(SellingController): return False + def get_depreciation_amount_in_je(self, journal_entry): + if journal_entry.accounts[0].debit_in_account_currency: + return journal_entry.accounts[0].debit_in_account_currency + else: + return journal_entry.accounts[0].credit_in_account_currency + @property def enable_discount_accounting(self): if not hasattr(self, "_enable_discount_accounting"): diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 2c92820a74..c862774060 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -184,6 +184,8 @@ class AccountsController(TransactionBase): frappe.throw(_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(d.idx)) elif getdate(self.posting_date) > getdate(d.service_end_date): frappe.throw(_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx)) + elif getdate(self.posting_date) > getdate(d.service_start_date): + frappe.throw(_("Row #{0}: Service Start Date cannot be before Invoice Posting Date").format(d.idx)) def validate_invoice_documents_schedule(self): self.validate_payment_schedule_dates() diff --git a/erpnext/crm/doctype/lead/test_lead.py b/erpnext/crm/doctype/lead/test_lead.py index 56bfc8f145..3882974022 100644 --- a/erpnext/crm/doctype/lead/test_lead.py +++ b/erpnext/crm/doctype/lead/test_lead.py @@ -23,6 +23,17 @@ class TestLead(unittest.TestCase): customer.customer_group = "_Test Customer Group" customer.insert() + #check whether lead contact is carried forward to the customer. + contact = frappe.db.get_value('Dynamic Link', { + "parenttype": "Contact", + "link_doctype": "Lead", + "link_name": customer.lead_name, + }, "parent") + + if contact: + contact_doc = frappe.get_doc("Contact", contact) + self.assertEqual(contact_doc.has_link(customer.doctype, customer.name), True) + def test_make_customer_from_organization(self): from erpnext.crm.doctype.lead.lead import make_customer diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json deleted file mode 100644 index 5efafd67fe..0000000000 --- a/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "charts": [], - "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Integrations Settings\", \"col\": 4}}]", - "creation": "2020-07-31 10:38:54.021237", - "docstatus": 0, - "doctype": "Workspace", - "for_user": "", - "hide_custom": 0, - "icon": "setting", - "idx": 0, - "label": "ERPNext Integrations Settings", - "links": [ - { - "hidden": 0, - "is_query_report": 0, - "label": "Integrations Settings", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Woocommerce Settings", - "link_count": 0, - "link_to": "Woocommerce Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Amazon MWS Settings", - "link_count": 0, - "link_to": "Amazon MWS Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Plaid Settings", - "link_count": 0, - "link_to": "Plaid Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Exotel Settings", - "link_count": 0, - "link_to": "Exotel Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - } - ], - "modified": "2021-11-23 04:30:33.106991", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "ERPNext Integrations Settings", - "owner": "Administrator", - "parent_page": "", - "public": 1, - "restrict_to_domain": "", - "roles": [], - "sequence_id": 11, - "shortcuts": [], - "title": "ERPNext Integrations Settings" -} \ No newline at end of file diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 9ceb6267a7..1d11f20ab7 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -374,7 +374,7 @@ scheduler_events = { "erpnext.selling.doctype.quotation.quotation.set_expired_status", "erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_status", "erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email", - "erpnext.non_profit.doctype.membership.membership.set_expired_status" + "erpnext.non_profit.doctype.membership.membership.set_expired_status", "erpnext.hr.doctype.interview.interview.send_daily_feedback_reminder" ], "daily_long": [ diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.js b/erpnext/hr/doctype/expense_claim/expense_claim.js index 665556301b..047945787d 100644 --- a/erpnext/hr/doctype/expense_claim/expense_claim.js +++ b/erpnext/hr/doctype/expense_claim/expense_claim.js @@ -171,7 +171,7 @@ frappe.ui.form.on("Expense Claim", { ['docstatus', '=', 1], ['employee', '=', frm.doc.employee], ['paid_amount', '>', 0], - ['paid_amount', '>', 'claimed_amount'] + ['status', '!=', 'Claimed'] ] }; }); diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index ec703614c8..2a079201b7 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -10,15 +10,17 @@ from erpnext.accounts.doctype.account.test_account import create_account from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.expense_claim.expense_claim import make_bank_entry -test_records = frappe.get_test_records('Expense Claim') test_dependencies = ['Employee'] -company_name = '_Test Company 4' +company_name = '_Test Company 3' class TestExpenseClaim(unittest.TestCase): + def tearDown(self): + frappe.db.rollback() + def test_total_expense_claim_for_project(self): - frappe.db.sql("""delete from `tabTask` where project = "_Test Project 1" """) - frappe.db.sql("""delete from `tabProject` where name = "_Test Project 1" """) + frappe.db.sql("""delete from `tabTask`""") + frappe.db.sql("""delete from `tabProject`""") frappe.db.sql("update `tabExpense Claim` set project = '', task = ''") project = frappe.get_doc({ @@ -37,12 +39,12 @@ class TestExpenseClaim(unittest.TestCase): task_name = task.name payable_account = get_payable_account(company_name) - make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", project.name, task_name) + make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3", project.name, task_name) self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 200) self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 200) - expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC4", project.name, task_name) + expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC3", project.name, task_name) self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 700) self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 700) @@ -54,7 +56,7 @@ class TestExpenseClaim(unittest.TestCase): def test_expense_claim_status(self): payable_account = get_payable_account(company_name) - expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4") + expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3") je_dict = make_bank_entry("Expense Claim", expense_claim.name) je = frappe.get_doc(je_dict) @@ -73,7 +75,7 @@ class TestExpenseClaim(unittest.TestCase): def test_expense_claim_gl_entry(self): payable_account = get_payable_account(company_name) taxes = generate_taxes() - expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", + expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3", do_not_submit=True, taxes=taxes) expense_claim.submit() @@ -84,9 +86,9 @@ class TestExpenseClaim(unittest.TestCase): self.assertTrue(gl_entries) expected_values = dict((d[0], d) for d in [ - ['Output Tax CGST - _TC4',18.0, 0.0], + ['Output Tax CGST - _TC3',18.0, 0.0], [payable_account, 0.0, 218.0], - ["Travel Expenses - _TC4", 200.0, 0.0] + ["Travel Expenses - _TC3", 200.0, 0.0] ]) for gle in gl_entries: @@ -102,7 +104,7 @@ class TestExpenseClaim(unittest.TestCase): "payable_account": payable_account, "approval_status": "Rejected", "expenses": - [{ "expense_type": "Travel", "default_account": "Travel Expenses - _TC4", "amount": 300, "sanctioned_amount": 200 }] + [{"expense_type": "Travel", "default_account": "Travel Expenses - _TC3", "amount": 300, "sanctioned_amount": 200}] }) expense_claim.submit() diff --git a/erpnext/hr/doctype/expense_claim/test_records.json b/erpnext/hr/doctype/expense_claim/test_records.json deleted file mode 100644 index fe51488c70..0000000000 --- a/erpnext/hr/doctype/expense_claim/test_records.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py index 46401a2dd8..1fe91399a0 100644 --- a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py @@ -4,6 +4,7 @@ import frappe from frappe.utils import add_days, add_months, getdate, nowdate import erpnext +from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type @@ -13,16 +14,19 @@ class TestLeaveAllocation(unittest.TestCase): def setUpClass(cls): frappe.db.sql("delete from `tabLeave Period`") - def test_overlapping_allocation(self): - frappe.db.sql("delete from `tabLeave Allocation`") + emp_id = make_employee("test_emp_leave_allocation@salary.com") + cls.employee = frappe.get_doc("Employee", emp_id) + + def tearDown(self): + frappe.db.rollback() - employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) + def test_overlapping_allocation(self): leaves = [ { "doctype": "Leave Allocation", "__islocal": 1, - "employee": employee.name, - "employee_name": employee.employee_name, + "employee": self.employee.name, + "employee_name": self.employee.employee_name, "leave_type": "_Test Leave Type", "from_date": getdate("2015-10-01"), "to_date": getdate("2015-10-31"), @@ -32,8 +36,8 @@ class TestLeaveAllocation(unittest.TestCase): { "doctype": "Leave Allocation", "__islocal": 1, - "employee": employee.name, - "employee_name": employee.employee_name, + "employee": self.employee.name, + "employee_name": self.employee.employee_name, "leave_type": "_Test Leave Type", "from_date": getdate("2015-09-01"), "to_date": getdate("2015-11-30"), @@ -45,40 +49,36 @@ class TestLeaveAllocation(unittest.TestCase): self.assertRaises(frappe.ValidationError, frappe.get_doc(leaves[1]).save) def test_invalid_period(self): - employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) - doc = frappe.get_doc({ "doctype": "Leave Allocation", "__islocal": 1, - "employee": employee.name, - "employee_name": employee.employee_name, + "employee": self.employee.name, + "employee_name": self.employee.employee_name, "leave_type": "_Test Leave Type", "from_date": getdate("2015-09-30"), "to_date": getdate("2015-09-1"), "new_leaves_allocated": 5 }) - #invalid period + # invalid period self.assertRaises(frappe.ValidationError, doc.save) def test_allocated_leave_days_over_period(self): - employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) doc = frappe.get_doc({ "doctype": "Leave Allocation", "__islocal": 1, - "employee": employee.name, - "employee_name": employee.employee_name, + "employee": self.employee.name, + "employee_name": self.employee.employee_name, "leave_type": "_Test Leave Type", "from_date": getdate("2015-09-1"), "to_date": getdate("2015-09-30"), "new_leaves_allocated": 35 }) - #allocated leave more than period + + # allocated leave more than period self.assertRaises(frappe.ValidationError, doc.save) def test_carry_forward_calculation(self): - frappe.db.sql("delete from `tabLeave Allocation`") - frappe.db.sql("delete from `tabLeave Ledger Entry`") leave_type = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1) leave_type.maximum_carry_forwarded_leaves = 10 leave_type.max_leaves_allowed = 30 @@ -86,6 +86,8 @@ class TestLeaveAllocation(unittest.TestCase): # initial leave allocation = 15 leave_allocation = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name, leave_type="_Test_CF_leave", from_date=add_months(nowdate(), -12), to_date=add_months(nowdate(), -1), @@ -95,6 +97,8 @@ class TestLeaveAllocation(unittest.TestCase): # carry forwarded leaves considering maximum_carry_forwarded_leaves # new_leaves = 15, carry_forwarded = 10 leave_allocation_1 = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name, leave_type="_Test_CF_leave", carry_forward=1) leave_allocation_1.submit() @@ -106,6 +110,8 @@ class TestLeaveAllocation(unittest.TestCase): # carry forwarded leaves considering max_leave_allowed # max_leave_allowed = 30, new_leaves = 25, carry_forwarded = 5 leave_allocation_2 = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name, leave_type="_Test_CF_leave", carry_forward=1, new_leaves_allocated=25) @@ -114,8 +120,6 @@ class TestLeaveAllocation(unittest.TestCase): self.assertEqual(leave_allocation_2.unused_leaves, 5) def test_carry_forward_leaves_expiry(self): - frappe.db.sql("delete from `tabLeave Allocation`") - frappe.db.sql("delete from `tabLeave Ledger Entry`") leave_type = create_leave_type( leave_type_name="_Test_CF_leave_expiry", is_carry_forward=1, @@ -124,6 +128,8 @@ class TestLeaveAllocation(unittest.TestCase): # initial leave allocation leave_allocation = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name, leave_type="_Test_CF_leave_expiry", from_date=add_months(nowdate(), -24), to_date=add_months(nowdate(), -12), @@ -131,6 +137,8 @@ class TestLeaveAllocation(unittest.TestCase): leave_allocation.submit() leave_allocation = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name, leave_type="_Test_CF_leave_expiry", from_date=add_days(nowdate(), -90), to_date=add_days(nowdate(), 100), @@ -142,6 +150,8 @@ class TestLeaveAllocation(unittest.TestCase): # leave allocation with carry forward of only new leaves allocated leave_allocation_1 = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name, leave_type="_Test_CF_leave_expiry", carry_forward=1, from_date=add_months(nowdate(), 6), @@ -151,9 +161,10 @@ class TestLeaveAllocation(unittest.TestCase): self.assertEqual(leave_allocation_1.unused_leaves, leave_allocation.new_leaves_allocated) def test_creation_of_leave_ledger_entry_on_submit(self): - frappe.db.sql("delete from `tabLeave Allocation`") - - leave_allocation = create_leave_allocation() + leave_allocation = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name + ) leave_allocation.submit() leave_ledger_entry = frappe.get_all('Leave Ledger Entry', fields='*', filters=dict(transaction_name=leave_allocation.name)) @@ -168,10 +179,10 @@ class TestLeaveAllocation(unittest.TestCase): self.assertFalse(frappe.db.exists("Leave Ledger Entry", {'transaction_name':leave_allocation.name})) def test_leave_addition_after_submit(self): - frappe.db.sql("delete from `tabLeave Allocation`") - frappe.db.sql("delete from `tabLeave Ledger Entry`") - - leave_allocation = create_leave_allocation() + leave_allocation = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name + ) leave_allocation.submit() self.assertTrue(leave_allocation.total_leaves_allocated, 15) leave_allocation.new_leaves_allocated = 40 @@ -179,44 +190,55 @@ class TestLeaveAllocation(unittest.TestCase): self.assertTrue(leave_allocation.total_leaves_allocated, 40) def test_leave_subtraction_after_submit(self): - frappe.db.sql("delete from `tabLeave Allocation`") - frappe.db.sql("delete from `tabLeave Ledger Entry`") - leave_allocation = create_leave_allocation() + leave_allocation = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name + ) leave_allocation.submit() self.assertTrue(leave_allocation.total_leaves_allocated, 15) leave_allocation.new_leaves_allocated = 10 leave_allocation.submit() self.assertTrue(leave_allocation.total_leaves_allocated, 10) - def test_against_leave_application_validation_after_submit(self): - frappe.db.sql("delete from `tabLeave Allocation`") - frappe.db.sql("delete from `tabLeave Ledger Entry`") + def test_validation_against_leave_application_after_submit(self): + from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list - leave_allocation = create_leave_allocation() + make_holiday_list() + frappe.db.set_value("Company", self.employee.company, "default_holiday_list", "Salary Slip Test Holiday List") + + leave_allocation = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name + ) leave_allocation.submit() self.assertTrue(leave_allocation.total_leaves_allocated, 15) - employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) + leave_application = frappe.get_doc({ "doctype": 'Leave Application', - "employee": employee.name, + "employee": self.employee.name, "leave_type": "_Test Leave Type", "from_date": add_months(nowdate(), 2), "to_date": add_months(add_days(nowdate(), 10), 2), - "company": erpnext.get_default_company() or "_Test Company", + "company": self.employee.company, "docstatus": 1, "status": "Approved", "leave_approver": 'test@example.com' }) leave_application.submit() - leave_allocation.new_leaves_allocated = 8 - leave_allocation.total_leaves_allocated = 8 + leave_application.reload() + + # allocate less leaves than the ones which are already approved + leave_allocation.new_leaves_allocated = leave_application.total_leave_days - 1 + leave_allocation.total_leaves_allocated = leave_application.total_leave_days - 1 self.assertRaises(frappe.ValidationError, leave_allocation.submit) def create_leave_allocation(**args): args = frappe._dict(args) - employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) - leave_allocation = frappe.get_doc({ + emp_id = make_employee("test_emp_leave_allocation@salary.com") + employee = frappe.get_doc("Employee", emp_id) + + return frappe.get_doc({ "doctype": "Leave Allocation", "__islocal": 1, "employee": args.employee or employee.name, @@ -227,6 +249,5 @@ def create_leave_allocation(**args): "carry_forward": args.carry_forward or 0, "to_date": args.to_date or add_months(nowdate(), 12) }) - return leave_allocation test_dependencies = ["Employee", "Leave Type"] diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d9cedab52a..716dcc0f43 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -317,4 +317,6 @@ erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents erpnext.patches.v14_0.migrate_crm_settings erpnext.patches.v13_0.rename_ksa_qr_field erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021 -erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template \ No newline at end of file +erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template +erpnext.patches.v13_0.update_tax_category_for_rcm +execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') diff --git a/erpnext/patches/v11_0/add_default_dispatch_notification_template.py b/erpnext/patches/v11_0/add_default_dispatch_notification_template.py index 08006ad01b..c7771a5f19 100644 --- a/erpnext/patches/v11_0/add_default_dispatch_notification_template.py +++ b/erpnext/patches/v11_0/add_default_dispatch_notification_template.py @@ -22,4 +22,5 @@ def execute(): delivery_settings = frappe.get_doc("Delivery Settings") delivery_settings.dispatch_template = _("Dispatch Notification") + delivery_settings.flags.ignore_links = True delivery_settings.save() diff --git a/erpnext/patches/v12_0/create_itc_reversal_custom_fields.py b/erpnext/patches/v12_0/create_itc_reversal_custom_fields.py index d157aad8f2..d4fbded5a3 100644 --- a/erpnext/patches/v12_0/create_itc_reversal_custom_fields.py +++ b/erpnext/patches/v12_0/create_itc_reversal_custom_fields.py @@ -97,6 +97,8 @@ def execute(): 'itc_central_tax': 0, 'itc_cess_amount': 0 }) + if not gst_accounts: + continue if d.account_head in gst_accounts.get('igst_account'): amount_map[d.parent]['itc_integrated_tax'] += d.amount diff --git a/erpnext/patches/v13_0/add_default_interview_notification_templates.py b/erpnext/patches/v13_0/add_default_interview_notification_templates.py index 0208ca914e..6b5de52e2b 100644 --- a/erpnext/patches/v13_0/add_default_interview_notification_templates.py +++ b/erpnext/patches/v13_0/add_default_interview_notification_templates.py @@ -32,4 +32,5 @@ def execute(): hr_settings = frappe.get_doc('HR Settings') hr_settings.interview_reminder_template = _('Interview Reminder') hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder') + hr_settings.flags.ignore_links = True hr_settings.save() diff --git a/erpnext/patches/v13_0/update_tax_category_for_rcm.py b/erpnext/patches/v13_0/update_tax_category_for_rcm.py new file mode 100644 index 0000000000..7af2366bf0 --- /dev/null +++ b/erpnext/patches/v13_0/update_tax_category_for_rcm.py @@ -0,0 +1,31 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +from erpnext.regional.india import states + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + create_custom_fields({ + 'Tax Category': [ + dict(fieldname='is_inter_state', label='Is Inter State', + fieldtype='Check', insert_after='disabled', print_hide=1), + dict(fieldname='is_reverse_charge', label='Is Reverse Charge', fieldtype='Check', + insert_after='is_inter_state', print_hide=1), + dict(fieldname='tax_category_column_break', fieldtype='Column Break', + insert_after='is_reverse_charge'), + dict(fieldname='gst_state', label='Source State', fieldtype='Select', + options='\n'.join(states), insert_after='company') + ] + }, update=True) + + tax_category = frappe.qb.DocType("Tax Category") + + frappe.qb.update(tax_category).set( + tax_category.is_reverse_charge, 1 + ).where( + tax_category.name.isin(['Reverse Charge Out-State', 'Reverse Charge In-State']) + ).run() \ No newline at end of file diff --git a/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py index 8b1752b2c7..120182a80e 100644 --- a/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py +++ b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py @@ -24,4 +24,5 @@ def execute(): hr_settings = frappe.get_doc("HR Settings") hr_settings.exit_questionnaire_notification_template = template + hr_settings.flags.ignore_links = True hr_settings.save() diff --git a/erpnext/projects/doctype/project/project.js b/erpnext/projects/doctype/project/project.js index 31460f66ea..4f19bbd516 100644 --- a/erpnext/projects/doctype/project/project.js +++ b/erpnext/projects/doctype/project/project.js @@ -59,22 +59,16 @@ frappe.ui.form.on("Project", { frm.trigger('show_dashboard'); } - frm.events.set_buttons(frm); + frm.trigger("set_custom_buttons"); }, - set_buttons: function(frm) { + set_custom_buttons: function(frm) { if (!frm.is_new()) { frm.add_custom_button(__('Duplicate Project with Tasks'), () => { frm.events.create_duplicate(frm); - }); - - frm.add_custom_button(__('Completed'), () => { - frm.events.set_status(frm, 'Completed'); - }, __('Set Status')); + }, __("Actions")); - frm.add_custom_button(__('Cancelled'), () => { - frm.events.set_status(frm, 'Cancelled'); - }, __('Set Status')); + frm.trigger("set_project_status_button"); if (frappe.model.can_read("Task")) { @@ -83,7 +77,7 @@ frappe.ui.form.on("Project", { "project": frm.doc.name }; frappe.set_route("List", "Task", "Gantt"); - }); + }, __("View")); frm.add_custom_button(__("Kanban Board"), () => { frappe.call('erpnext.projects.doctype.project.project.create_kanban_board_if_not_exists', { @@ -91,13 +85,35 @@ frappe.ui.form.on("Project", { }).then(() => { frappe.set_route('List', 'Task', 'Kanban', frm.doc.project_name); }); - }); + }, __("View")); } } }, + set_project_status_button: function(frm) { + frm.add_custom_button(__('Set Project Status'), () => { + let d = new frappe.ui.Dialog({ + "title": __("Set Project Status"), + "fields": [ + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Completed\nCancelled", + }, + ], + primary_action: function() { + frm.events.set_status(frm, d.get_values().status); + d.hide(); + }, + primary_action_label: __("Set Project Status") + }).show(); + }, __("Actions")); + }, + create_duplicate: function(frm) { return new Promise(resolve => { frappe.prompt('Project Name', (data) => { @@ -117,7 +133,9 @@ frappe.ui.form.on("Project", { set_status: function(frm, status) { frappe.confirm(__('Set Project and all Tasks to status {0}?', [status.bold()]), () => { frappe.xcall('erpnext.projects.doctype.project.project.set_project_status', - {project: frm.doc.name, status: status}).then(() => { /* page will auto reload */ }); + {project: frm.doc.name, status: status}).then(() => { + frm.reload_doc(); + }); }); }, diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 773d53c552..3791741663 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -680,7 +680,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe var item = frappe.get_doc(cdt, cdn); frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]); - // check if child doctype is Sales Order Item/Qutation Item and calculate the rate + // check if child doctype is Sales Order Item/Quotation Item and calculate the rate if (in_list(["Quotation Item", "Sales Order Item", "Delivery Note Item", "Sales Invoice Item", "POS Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Purchase Receipt Item"]), cdt) this.apply_pricing_rule_on_item(item); else @@ -1582,25 +1582,27 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe _set_values_for_item_list(children) { var me = this; - var price_list_rate_changed = false; var items_rule_dict = {}; for(var i=0, l=children.length; i Tuple: to_date = filters["to_date"] - _func = itemgetter(1) + columns = get_columns(filters) + + item_details = FIFOSlots(filters).generate() + data = format_report_data(filters, item_details, to_date) + + chart_data = get_chart_data(data, filters) + return columns, data, None, chart_data + +def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> List[Dict]: + "Returns ordered, formatted data with ranges." + _func = itemgetter(1) data = [] + for item, item_dict in item_details.items(): earliest_age, latest_age = 0, 0 + details = item_dict["details"] fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func) - details = item_dict["details"] if not fifo_queue: continue @@ -31,23 +42,22 @@ def execute(filters=None): latest_age = date_diff(to_date, fifo_queue[-1][1]) range1, range2, range3, above_range3 = get_range_age(filters, fifo_queue, to_date, item_dict) - row = [details.name, details.item_name, - details.description, details.item_group, details.brand] + row = [details.name, details.item_name, details.description, + details.item_group, details.brand] if filters.get("show_warehouse_wise_stock"): row.append(details.warehouse) row.extend([item_dict.get("total_qty"), average_age, range1, range2, range3, above_range3, - earliest_age, latest_age, details.stock_uom]) + earliest_age, latest_age, + details.stock_uom]) data.append(row) - chart_data = get_chart_data(data, filters) - - return columns, data, None, chart_data + return data -def get_average_age(fifo_queue, to_date): +def get_average_age(fifo_queue: List, to_date: str) -> float: batch_age = age_qty = total_qty = 0.0 for batch in fifo_queue: batch_age = date_diff(to_date, batch[1]) @@ -61,7 +71,7 @@ def get_average_age(fifo_queue, to_date): return flt(age_qty / total_qty, 2) if total_qty else 0.0 -def get_range_age(filters, fifo_queue, to_date, item_dict): +def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: Dict) -> Tuple: range1 = range2 = range3 = above_range3 = 0.0 for item in fifo_queue: @@ -79,7 +89,7 @@ def get_range_age(filters, fifo_queue, to_date, item_dict): return range1, range2, range3, above_range3 -def get_columns(filters): +def get_columns(filters: Filters) -> List[Dict]: range_columns = [] setup_ageing_columns(filters, range_columns) columns = [ @@ -164,106 +174,7 @@ def get_columns(filters): return columns -def get_fifo_queue(filters, sle=None): - item_details = {} - transferred_item_details = {} - serial_no_batch_purchase_details = {} - - if sle == None: - sle = get_stock_ledger_entries(filters) - - for d in sle: - key = (d.name, d.warehouse) if filters.get('show_warehouse_wise_stock') else d.name - item_details.setdefault(key, {"details": d, "fifo_queue": []}) - fifo_queue = item_details[key]["fifo_queue"] - - transferred_item_key = (d.voucher_no, d.name, d.warehouse) - transferred_item_details.setdefault(transferred_item_key, []) - - if d.voucher_type == "Stock Reconciliation": - d.actual_qty = flt(d.qty_after_transaction) - flt(item_details[key].get("qty_after_transaction", 0)) - - serial_no_list = get_serial_nos(d.serial_no) if d.serial_no else [] - - if d.actual_qty > 0: - if transferred_item_details.get(transferred_item_key): - batch = transferred_item_details[transferred_item_key][0] - fifo_queue.append(batch) - transferred_item_details[transferred_item_key].pop(0) - else: - if serial_no_list: - for serial_no in serial_no_list: - if serial_no_batch_purchase_details.get(serial_no): - fifo_queue.append([serial_no, serial_no_batch_purchase_details.get(serial_no)]) - else: - serial_no_batch_purchase_details.setdefault(serial_no, d.posting_date) - fifo_queue.append([serial_no, d.posting_date]) - else: - fifo_queue.append([d.actual_qty, d.posting_date]) - else: - if serial_no_list: - fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_no_list] - else: - qty_to_pop = abs(d.actual_qty) - while qty_to_pop: - batch = fifo_queue[0] if fifo_queue else [0, None] - if 0 < flt(batch[0]) <= qty_to_pop: - # if batch qty > 0 - # not enough or exactly same qty in current batch, clear batch - qty_to_pop -= flt(batch[0]) - transferred_item_details[transferred_item_key].append(fifo_queue.pop(0)) - else: - # all from current batch - batch[0] = flt(batch[0]) - qty_to_pop - transferred_item_details[transferred_item_key].append([qty_to_pop, batch[1]]) - qty_to_pop = 0 - - item_details[key]["qty_after_transaction"] = d.qty_after_transaction - - if "total_qty" not in item_details[key]: - item_details[key]["total_qty"] = d.actual_qty - else: - item_details[key]["total_qty"] += d.actual_qty - - item_details[key]["has_serial_no"] = d.has_serial_no - - return item_details - -def get_stock_ledger_entries(filters): - return frappe.db.sql("""select - item.name, item.item_name, item_group, brand, description, item.stock_uom, item.has_serial_no, - actual_qty, posting_date, voucher_type, voucher_no, serial_no, batch_no, qty_after_transaction, warehouse - from `tabStock Ledger Entry` sle, - (select name, item_name, description, stock_uom, brand, item_group, has_serial_no - from `tabItem` {item_conditions}) item - where item_code = item.name and - company = %(company)s and - posting_date <= %(to_date)s and - is_cancelled != 1 - {sle_conditions} - order by posting_date, posting_time, sle.creation, actual_qty""" #nosec - .format(item_conditions=get_item_conditions(filters), - sle_conditions=get_sle_conditions(filters)), filters, as_dict=True) - -def get_item_conditions(filters): - conditions = [] - if filters.get("item_code"): - conditions.append("item_code=%(item_code)s") - if filters.get("brand"): - conditions.append("brand=%(brand)s") - - return "where {}".format(" and ".join(conditions)) if conditions else "" - -def get_sle_conditions(filters): - conditions = [] - if filters.get("warehouse"): - lft, rgt = frappe.db.get_value('Warehouse', filters.get("warehouse"), ['lft', 'rgt']) - conditions.append("""warehouse in (select wh.name from `tabWarehouse` wh - where wh.lft >= {0} and rgt <= {1})""".format(lft, rgt)) - - return "and {}".format(" and ".join(conditions)) if conditions else "" - -def get_chart_data(data, filters): +def get_chart_data(data: List, filters: Filters) -> Dict: if not data: return [] @@ -294,17 +205,201 @@ def get_chart_data(data, filters): "type" : "bar" } -def setup_ageing_columns(filters, range_columns): - for i, label in enumerate(["0-{range1}".format(range1=filters["range1"]), - "{range1}-{range2}".format(range1=cint(filters["range1"])+ 1, range2=filters["range2"]), - "{range2}-{range3}".format(range2=cint(filters["range2"])+ 1, range3=filters["range3"]), - "{range3}-{above}".format(range3=cint(filters["range3"])+ 1, above=_("Above"))]): - add_column(range_columns, label="Age ("+ label +")", fieldname='range' + str(i+1)) - -def add_column(range_columns, label, fieldname, fieldtype='Float', width=140): +def setup_ageing_columns(filters: Filters, range_columns: List): + ranges = [ + f"0 - {filters['range1']}", + f"{cint(filters['range1']) + 1} - {cint(filters['range2'])}", + f"{cint(filters['range2']) + 1} - {cint(filters['range3'])}", + f"{cint(filters['range3']) + 1} - {_('Above')}" + ] + for i, label in enumerate(ranges): + fieldname = 'range' + str(i+1) + add_column(range_columns, label=f"Age ({label})",fieldname=fieldname) + +def add_column(range_columns: List, label:str, fieldname: str, fieldtype: str = 'Float', width: int = 140): range_columns.append(dict( label=label, fieldname=fieldname, fieldtype=fieldtype, width=width )) + + +class FIFOSlots: + "Returns FIFO computed slots of inwarded stock as per date." + + def __init__(self, filters: Dict = None , sle: List = None): + self.item_details = {} + self.transferred_item_details = {} + self.serial_no_batch_purchase_details = {} + self.filters = filters + self.sle = sle + + def generate(self) -> Dict: + """ + Returns dict of the foll.g structure: + Key = Item A / (Item A, Warehouse A) + Key: { + 'details' -> Dict: ** item details **, + 'fifo_queue' -> List: ** list of lists containing entries/slots for existing stock, + consumed/updated and maintained via FIFO. ** + } + """ + if self.sle is None: + self.sle = self.__get_stock_ledger_entries() + + for d in self.sle: + key, fifo_queue, transferred_item_key = self.__init_key_stores(d) + + if d.voucher_type == "Stock Reconciliation": + prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) + d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) + + serial_nos = get_serial_nos(d.serial_no) if d.serial_no else [] + + if d.actual_qty > 0: + self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos) + else: + self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos) + + self.__update_balances(d, key) + + return self.item_details + + def __init_key_stores(self, row: Dict) -> Tuple: + "Initialise keys and FIFO Queue." + + key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name + self.item_details.setdefault(key, {"details": row, "fifo_queue": []}) + fifo_queue = self.item_details[key]["fifo_queue"] + + transferred_item_key = (row.voucher_no, row.name, row.warehouse) + self.transferred_item_details.setdefault(transferred_item_key, []) + + return key, fifo_queue, transferred_item_key + + def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List): + "Update FIFO Queue on inward stock." + + if self.transferred_item_details.get(transfer_key): + # inward/outward from same voucher, item & warehouse + slot = self.transferred_item_details[transfer_key].pop(0) + fifo_queue.append(slot) + else: + if not serial_nos: + if fifo_queue and flt(fifo_queue[0][0]) < 0: + # neutralize negative stock by adding positive stock + fifo_queue[0][0] += flt(row.actual_qty) + fifo_queue[0][1] = row.posting_date + else: + fifo_queue.append([flt(row.actual_qty), row.posting_date]) + return + + for serial_no in serial_nos: + if self.serial_no_batch_purchase_details.get(serial_no): + fifo_queue.append([serial_no, self.serial_no_batch_purchase_details.get(serial_no)]) + else: + self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date) + fifo_queue.append([serial_no, row.posting_date]) + + def __compute_outgoing_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List): + "Update FIFO Queue on outward stock." + if serial_nos: + fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos] + return + + qty_to_pop = abs(row.actual_qty) + while qty_to_pop: + slot = fifo_queue[0] if fifo_queue else [0, None] + if 0 < flt(slot[0]) <= qty_to_pop: + # qty to pop >= slot qty + # if +ve and not enough or exactly same balance in current slot, consume whole slot + qty_to_pop -= flt(slot[0]) + self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) + elif not fifo_queue: + # negative stock, no balance but qty yet to consume + fifo_queue.append([-(qty_to_pop), row.posting_date]) + self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date]) + qty_to_pop = 0 + else: + # qty to pop < slot qty, ample balance + # consume actual_qty from first slot + slot[0] = flt(slot[0]) - qty_to_pop + self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]]) + qty_to_pop = 0 + + def __update_balances(self, row: Dict, key: Union[Tuple, str]): + self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction + + if "total_qty" not in self.item_details[key]: + self.item_details[key]["total_qty"] = row.actual_qty + else: + self.item_details[key]["total_qty"] += row.actual_qty + + self.item_details[key]["has_serial_no"] = row.has_serial_no + + def __get_stock_ledger_entries(self) -> List[Dict]: + sle = frappe.qb.DocType("Stock Ledger Entry") + item = self.__get_item_query() # used as derived table in sle query + + sle_query = ( + frappe.qb.from_(sle).from_(item) + .select( + item.name, item.item_name, item.item_group, + item.brand, item.description, + item.stock_uom, item.has_serial_no, + sle.actual_qty, sle.posting_date, + sle.voucher_type, sle.voucher_no, + sle.serial_no, sle.batch_no, + sle.qty_after_transaction, sle.warehouse + ).where( + (sle.item_code == item.name) + & (sle.company == self.filters.get("company")) + & (sle.posting_date <= self.filters.get("to_date")) + & (sle.is_cancelled != 1) + ) + ) + + if self.filters.get("warehouse"): + sle_query = self.__get_warehouse_conditions(sle, sle_query) + + sle_query = sle_query.orderby( + sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty + ) + + return sle_query.run(as_dict=True) + + def __get_item_query(self) -> str: + item_table = frappe.qb.DocType("Item") + + item = frappe.qb.from_("Item").select( + "name", "item_name", "description", "stock_uom", + "brand", "item_group", "has_serial_no" + ) + + if self.filters.get("item_code"): + item = item.where(item_table.item_code == self.filters.get("item_code")) + + if self.filters.get("brand"): + item = item.where(item_table.brand == self.filters.get("brand")) + + return item + + def __get_warehouse_conditions(self, sle, sle_query) -> str: + warehouse = frappe.qb.DocType("Warehouse") + lft, rgt = frappe.db.get_value( + "Warehouse", + self.filters.get("warehouse"), + ['lft', 'rgt'] + ) + + warehouse_results = ( + frappe.qb.from_(warehouse) + .select("name").where( + (warehouse.lft >= lft) + & (warehouse.rgt <= rgt) + ).run() + ) + warehouse_results = [x[0] for x in warehouse_results] + + return sle_query.where(sle.warehouse.isin(warehouse_results)) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md new file mode 100644 index 0000000000..5ffe97fd74 --- /dev/null +++ b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md @@ -0,0 +1,73 @@ +### Concept of FIFO Slots + +Since we need to know age-wise remaining stock, we maintain all the inward entries as slots. So each time stock comes in, a slot is added for the same. + +Eg. For Item A: +---------------------- +Date | Qty | Queue +---------------------- +1st | +50 | [[50, 1-12-2021]] +2nd | +20 | [[50, 1-12-2021], [20, 2-12-2021]] +---------------------- + +Now the queue can tell us the total stock and also how old the stock is. +Here, the balance qty is 70. +50 qty is (today-the 1st) days old +20 qty is (today-the 2nd) days old + +### Calculation of FIFO Slots + +#### Case 1: Outward from sufficient balance qty +---------------------- +Date | Qty | Queue +---------------------- +1st | +50 | [[50, 1-12-2021]] +2nd | -20 | [[30, 1-12-2021]] +2nd | +20 | [[30, 1-12-2021], [20, 2-12-2021]] + +Here after the first entry, while issuing 20 qty: +- **since 20 is lesser than the balance**, **qty_to_pop (20)** is simply consumed from first slot (FIFO consumption) +- Any inward entry after as usual will get its own slot added to the queue + +#### Case 2: Outward from sufficient cumulative (slots) balance qty +---------------------- +Date | Qty | Queue +---------------------- +1st | +50 | [[50, 1-12-2021]] +2nd | +20 | [[50, 1-12-2021], [20, 2-12-2021]] +2nd | -60 | [[10, 2-12-2021]] + +- Consumption happens slot wise. First slot 1 is consumed +- Since **qty_to_pop (60) is greater than slot 1 qty (50)**, the entire slot is consumed and popped +- Now the queue is [[20, 2-12-2021]], and **qty_to_pop=10** (remaining qty to pop) +- It then goes ahead to the next slot and consumes 10 from it +- Now the queue is [[10, 2-12-2021]] + +#### Case 3: Outward from insufficient balance qty +> This case is possible only if **Allow Negative Stock** was enabled at some point/is enabled. + +---------------------- +Date | Qty | Queue +---------------------- +1st | +50 | [[50, 1-12-2021]] +2nd | -60 | [[-10, 1-12-2021]] + +- Since **qty_to_pop (60)** is more than the balance in slot 1, the entire slot is consumed and popped +- Now the queue is **empty**, and **qty_to_pop=10** (remaining qty to pop) +- Since we still have more to consume, we append the balance since 60 is issued from 50 i.e. -10. +- We register this negative value, since the stock issue has caused the balance to become negative + +Now when stock is inwarded: +- Instead of adding a slot we check if there are any negative balances. +- If yes, we keep adding positive stock to it until we make the balance positive. +- Once the balance is positive, the next inward entry will add a new slot in the queue + +Eg: +---------------------- +Date | Qty | Queue +---------------------- +1st | +50 | [[50, 1-12-2021]] +2nd | -60 | [[-10, 1-12-2021]] +3rd | +5 | [[-5, 3-12-2021]] +4th | +10 | [[5, 4-12-2021]] +4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]] \ No newline at end of file diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py new file mode 100644 index 0000000000..949bb7c15a --- /dev/null +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -0,0 +1,126 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots +from erpnext.tests.utils import ERPNextTestCase + + +class TestStockAgeing(ERPNextTestCase): + def setUp(self) -> None: + self.filters = frappe._dict( + company="_Test Company", + to_date="2021-12-10" + ) + + def test_normal_inward_outward_queue(self): + "Reference: Case 1 in stock_ageing_fifo_logic.md" + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=30, qty_after_transaction=30, + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=20, qty_after_transaction=50, + posting_date="2021-12-02", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-10), qty_after_transaction=40, + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, serial_no=None + ) + ] + + slots = FIFOSlots(self.filters, sle).generate() + + self.assertTrue(slots["Flask Item"]["fifo_queue"]) + result = slots["Flask Item"] + queue = result["fifo_queue"] + + self.assertEqual(result["qty_after_transaction"], result["total_qty"]) + self.assertEqual(queue[0][0], 20.0) + + def test_insufficient_balance(self): + "Reference: Case 3 in stock_ageing_fifo_logic.md" + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=(-30), qty_after_transaction=(-30), + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=20, qty_after_transaction=(-10), + posting_date="2021-12-02", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=20, qty_after_transaction=10, + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=10, qty_after_transaction=20, + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="004", + has_serial_no=False, serial_no=None + ) + ] + + slots = FIFOSlots(self.filters, sle).generate() + + result = slots["Flask Item"] + queue = result["fifo_queue"] + + self.assertEqual(result["qty_after_transaction"], result["total_qty"]) + self.assertEqual(queue[0][0], 10.0) + self.assertEqual(queue[1][0], 10.0) + + def test_stock_reconciliation(self): + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=30, qty_after_transaction=30, + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=50, + posting_date="2021-12-02", voucher_type="Stock Reconciliation", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-10), qty_after_transaction=40, + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, serial_no=None + ) + ] + + slots = FIFOSlots(self.filters, sle).generate() + + result = slots["Flask Item"] + queue = result["fifo_queue"] + + self.assertEqual(result["qty_after_transaction"], result["total_qty"]) + self.assertEqual(queue[0][0], 20.0) + self.assertEqual(queue[1][0], 20.0) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 3c7b26bb1b..b4f43a7fef 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -9,7 +9,7 @@ from frappe import _ from frappe.utils import cint, date_diff, flt, getdate import erpnext -from erpnext.stock.report.stock_ageing.stock_ageing import get_average_age, get_fifo_queue +from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition from erpnext.stock.utils import add_additional_uom_columns, is_reposting_item_valuation_in_progress @@ -33,7 +33,7 @@ def execute(filters=None): if filters.get('show_stock_ageing_data'): filters['show_warehouse_wise_stock'] = True - item_wise_fifo_queue = get_fifo_queue(filters, sle) + item_wise_fifo_queue = FIFOSlots(filters, sle).generate() # if no stock ledger entry found return if not sle: diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js index c484516a16..31f389f236 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js @@ -8,7 +8,8 @@ const DIFFERNCE_FIELD_NAMES = [ "fifo_value_diff", "fifo_valuation_diff", "valuation_diff", - "fifo_difference_diff" + "fifo_difference_diff", + "diff_value_diff" ]; frappe.query_reports["Stock Ledger Invariant Check"] = { diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index ca47a1ec5b..48753b0edd 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -50,6 +50,7 @@ def get_stock_ledger_entries(filters): def add_invariant_check_fields(sles): balance_qty = 0.0 + balance_stock_value = 0.0 for idx, sle in enumerate(sles): queue = json.loads(sle.stock_queue) @@ -60,6 +61,7 @@ def add_invariant_check_fields(sles): fifo_value += qty * rate balance_qty += sle.actual_qty + balance_stock_value += sle.stock_value_difference if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: balance_qty = sle.qty_after_transaction @@ -70,6 +72,7 @@ def add_invariant_check_fields(sles): sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None ) sle.expected_qty_after_transaction = balance_qty + sle.stock_value_from_diff = balance_stock_value # set difference fields sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction @@ -81,6 +84,7 @@ def add_invariant_check_fields(sles): sle.valuation_diff = ( sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None ) + sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value if idx > 0: sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value @@ -191,12 +195,21 @@ def get_columns(): "fieldtype": "Float", "label": "D - E", }, - { "fieldname": "stock_value_difference", "fieldtype": "Float", "label": "(F) Stock Value Difference", }, + { + "fieldname": "stock_value_from_diff", + "fieldtype": "Float", + "label": "Balance Stock Value using (F)", + }, + { + "fieldname": "diff_value_diff", + "fieldtype": "Float", + "label": "K - D", + }, { "fieldname": "fifo_stock_diff", "fieldtype": "Float", diff --git a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py index 4d1491b1b5..22bdb89198 100644 --- a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py +++ b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py @@ -9,7 +9,7 @@ import frappe from frappe import _ from frappe.utils import flt -from erpnext.stock.report.stock_ageing.stock_ageing import get_average_age, get_fifo_queue +from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age from erpnext.stock.report.stock_balance.stock_balance import ( get_item_details, get_item_warehouse_map, @@ -33,7 +33,7 @@ def execute(filters=None): item_map = get_item_details(items, sle, filters) iwb_map = get_item_warehouse_map(filters, sle) warehouse_list = get_warehouse_list(filters) - item_ageing = get_fifo_queue(filters) + item_ageing = FIFOSlots(filters).generate() data = [] item_balance = {} item_value = {} diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index 14cec46ad4..7a0a5e506f 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -98,6 +98,7 @@ class TestIssue(TestSetUp): issue.save() self.assertEqual(issue.on_hold_since, frappe.flags.current_time) + self.assertFalse(issue.resolution_by) creation = get_datetime("2020-03-04 5:00") frappe.flags.current_time = get_datetime("2020-03-04 5:00") diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index c94700bdc5..b3348f1e1e 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -476,7 +476,7 @@ def update_response_and_resolution_metrics(doc, apply_sla_for_resolution): priority = get_response_and_resolution_duration(doc) start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation) set_response_by(doc, start_date_time, priority) - if apply_sla_for_resolution: + if apply_sla_for_resolution and not doc.get('on_hold_since'): # resolution_by is reset if on hold set_resolution_by(doc, start_date_time, priority) @@ -624,9 +624,6 @@ def reset_resolution_metrics(doc): if doc.meta.has_field("user_resolution_time"): doc.user_resolution_time = None - if doc.meta.has_field("agreement_status"): - doc.agreement_status = "First Response Due" - # called via hooks on communication update def on_communication_update(doc, status):