diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js index 335f8502c7..dbf362234e 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -14,6 +14,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }); }, + onload: function (frm) { + frm.trigger('bank_account'); + }, + refresh: function (frm) { frappe.require("bank-reconciliation-tool.bundle.js", () => frm.trigger("make_reconciliation_tool") @@ -51,7 +55,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { bank_account: function (frm) { frappe.db.get_value( "Bank Account", - frm.bank_account, + frm.doc.bank_account, "account", (r) => { frappe.db.get_value( diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index ba751c081b..cf8affdd01 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -586,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase): item_price.insert() pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10) pr.save() - pos_inv = create_pos_invoice(qty=1, do_not_submit=1) - pos_inv.items[0].rate = 300 - pos_inv.save() - self.assertEquals(pos_inv.items[0].discount_percentage, 10) - # rate shouldn't change - self.assertEquals(pos_inv.items[0].rate, 405) - pos_inv.ignore_pricing_rule = 1 - pos_inv.items[0].rate = 300 - pos_inv.save() - self.assertEquals(pos_inv.ignore_pricing_rule, 1) - # rate should change since pricing rules are ignored - self.assertEquals(pos_inv.items[0].rate, 300) + try: + pos_inv = create_pos_invoice(qty=1, do_not_submit=1) + pos_inv.items[0].rate = 300 + pos_inv.save() + self.assertEquals(pos_inv.items[0].discount_percentage, 10) + # rate shouldn't change + self.assertEquals(pos_inv.items[0].rate, 405) - item_price.delete() - pos_inv.delete() - pr.delete() + pos_inv.ignore_pricing_rule = 1 + pos_inv.save() + self.assertEquals(pos_inv.ignore_pricing_rule, 1) + # rate should reset since pricing rules are ignored + self.assertEquals(pos_inv.items[0].rate, 450) + + pos_inv.items[0].rate = 300 + pos_inv.save() + self.assertEquals(pos_inv.items[0].rate, 300) + + finally: + item_price.delete() + pos_inv.delete() + pr.delete() def create_pos_invoice(**args): diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 0720d9b2e9..ddca68a57b 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -84,12 +84,20 @@ class POSInvoiceMergeLog(Document): sales_invoice.set_posting_time = 1 sales_invoice.posting_date = getdate(self.posting_date) sales_invoice.save() + self.write_off_fractional_amount(sales_invoice, data) sales_invoice.submit() self.consolidated_invoice = sales_invoice.name return sales_invoice.name + def write_off_fractional_amount(self, invoice, data): + pos_invoice_grand_total = sum(d.grand_total for d in data) + + if abs(pos_invoice_grand_total - invoice.grand_total) < 1: + invoice.write_off_amount += -1 * (pos_invoice_grand_total - invoice.grand_total) + invoice.save() + def process_merging_into_credit_note(self, data): credit_note = self.get_new_sales_invoice() credit_note.is_return = 1 @@ -102,6 +110,7 @@ class POSInvoiceMergeLog(Document): # TODO: return could be against multiple sales invoice which could also have been consolidated? # credit_note.return_against = self.consolidated_invoice credit_note.save() + self.write_off_fractional_amount(credit_note, data) credit_note.submit() self.consolidated_credit_note = credit_note.name @@ -135,9 +144,15 @@ class POSInvoiceMergeLog(Document): i.uom == item.uom and i.net_rate == item.net_rate and i.warehouse == item.warehouse): found = True i.qty = i.qty + item.qty + i.amount = i.amount + item.net_amount + i.net_amount = i.amount + i.base_amount = i.base_amount + item.base_net_amount + i.base_net_amount = i.base_amount if not found: item.rate = item.net_rate + item.amount = item.net_amount + item.base_amount = item.base_net_amount item.price_list_rate = 0 si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"}) items.append(si_item) @@ -169,6 +184,7 @@ class POSInvoiceMergeLog(Document): found = True if not found: payments.append(payment) + rounding_adjustment += doc.rounding_adjustment rounded_total += doc.rounded_total base_rounding_adjustment += doc.base_rounding_adjustment diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index 3555da83a4..5930aa097f 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -12,6 +12,7 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import ( consolidate_pos_invoices, ) +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry class TestPOSInvoiceMergeLog(unittest.TestCase): @@ -150,3 +151,132 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") + + + def test_consolidation_round_off_error_1(self): + ''' + Test round off error in consolidated invoice creation if POS Invoice has inclusive tax + ''' + + frappe.db.sql("delete from `tabPOS Invoice`") + + try: + make_stock_entry( + to_warehouse="_Test Warehouse - _TC", + item_code="_Test Item", + rate=8000, + qty=10, + ) + + init_user_and_profile() + + inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True) + inv.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000 + }) + inv.insert() + inv.submit() + + inv2 = create_pos_invoice(qty=3, rate=10000, do_not_save=True) + inv2.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000 + }) + inv2.insert() + inv2.submit() + + consolidate_pos_invoices() + + inv.load_from_db() + consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) + self.assertEqual(consolidated_invoice.outstanding_amount, 0) + self.assertEqual(consolidated_invoice.status, 'Paid') + + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") + + def test_consolidation_round_off_error_2(self): + ''' + Test the same case as above but with an Unpaid POS Invoice + ''' + frappe.db.sql("delete from `tabPOS Invoice`") + + try: + make_stock_entry( + to_warehouse="_Test Warehouse - _TC", + item_code="_Test Item", + rate=8000, + qty=10, + ) + + init_user_and_profile() + + inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True) + inv.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000 + }) + inv.insert() + inv.submit() + + inv2 = create_pos_invoice(qty=6, rate=10000, do_not_save=True) + inv2.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000 + }) + inv2.insert() + inv2.submit() + + inv3 = create_pos_invoice(qty=3, rate=600, do_not_save=True) + inv3.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000 + }) + inv3.insert() + inv3.submit() + + consolidate_pos_invoices() + + inv.load_from_db() + consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) + self.assertEqual(consolidated_invoice.outstanding_amount, 800) + self.assertNotEqual(consolidated_invoice.status, 'Paid') + + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index ac96b045a2..933fda8a0a 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -249,13 +249,17 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa "free_item_data": [], "parent": args.parent, "parenttype": args.parenttype, - "child_docname": args.get('child_docname') + "child_docname": args.get('child_docname'), }) if args.ignore_pricing_rule or not args.item_code: if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"): - item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), - item_details, args.get('item_code')) + item_details = remove_pricing_rule_for_item( + args.get("pricing_rules"), + item_details, + item_code=args.get("item_code"), + rate=args.get("price_list_rate"), + ) return item_details update_args_for_pricing_rule(args) @@ -308,8 +312,12 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa if not doc: return item_details elif args.get("pricing_rules"): - item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), - item_details, args.get('item_code')) + item_details = remove_pricing_rule_for_item( + args.get("pricing_rules"), + item_details, + item_code=args.get("item_code"), + rate=args.get("price_list_rate"), + ) return item_details @@ -390,7 +398,7 @@ def apply_price_discount_rule(pricing_rule, item_details, args): item_details[field] += (pricing_rule.get(field, 0) if pricing_rule else args.get(field, 0)) -def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): +def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, rate=None): from erpnext.accounts.doctype.pricing_rule.utils import ( get_applied_pricing_rules, get_pricing_rule_items, @@ -403,6 +411,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): if pricing_rule.rate_or_discount == 'Discount Percentage': item_details.discount_percentage = 0.0 item_details.discount_amount = 0.0 + item_details.rate = rate or 0.0 if pricing_rule.rate_or_discount == 'Discount Amount': item_details.discount_amount = 0.0 @@ -421,6 +430,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): item_details.applied_on_items = ','.join(items) item_details.pricing_rules = '' + item_details.pricing_rule_removed = True return item_details @@ -432,9 +442,12 @@ def remove_pricing_rules(item_list): out = [] for item in item_list: item = frappe._dict(item) - if item.get('pricing_rules'): - out.append(remove_pricing_rule_for_item(item.get("pricing_rules"), - item, item.item_code)) + if item.get("pricing_rules"): + out.append( + remove_pricing_rule_for_item( + item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate") + ) + ) return out diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 968137e7f8..8338a5b0ad 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -628,6 +628,46 @@ class TestPricingRule(unittest.TestCase): for doc in [si, si1]: doc.delete() + def test_remove_pricing_rule(self): + item = make_item("Water Flask") + make_item_price("Water Flask", "_Test Price List", 100) + + pricing_rule_record = { + "doctype": "Pricing Rule", + "title": "_Test Water Flask Rule", + "apply_on": "Item Code", + "price_or_product_discount": "Price", + "items": [{ + "item_code": "Water Flask", + }], + "selling": 1, + "currency": "INR", + "rate_or_discount": "Discount Percentage", + "discount_percentage": 20, + "company": "_Test Company" + } + rule = frappe.get_doc(pricing_rule_record) + rule.insert() + + si = create_sales_invoice(do_not_save=True, item_code="Water Flask") + si.selling_price_list = "_Test Price List" + si.save() + + self.assertEqual(si.items[0].price_list_rate, 100) + self.assertEqual(si.items[0].discount_percentage, 20) + self.assertEqual(si.items[0].rate, 80) + + si.ignore_pricing_rule = 1 + si.save() + + self.assertEqual(si.items[0].discount_percentage, 0) + self.assertEqual(si.items[0].rate, 100) + + si.delete() + rule.delete() + frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete() + item.delete() + def test_multiple_pricing_rules_with_min_qty(self): make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4, apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1") @@ -648,6 +688,7 @@ class TestPricingRule(unittest.TestCase): frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1") frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2") + test_dependencies = ["Campaign"] def make_pricing_rule(**args): diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 76d9cc7b49..2c3156175f 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -178,8 +178,8 @@ class PurchaseInvoice(BuyingController): if self.supplier and account.account_type != "Payable": frappe.throw( - _("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.") - .format(frappe.bold("Credit To")), title=_("Invalid Account") + _("Please ensure {} account {} is a Payable account. Change the account type to Payable or select a different account.") + .format(frappe.bold("Credit To"), frappe.bold(self.credit_to)), title=_("Invalid Account") ) self.party_account_currency = account.account_currency diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 4fa77186a0..b894f90c7e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -285,7 +285,7 @@ class SalesInvoice(SellingController): filters={ invoice_or_credit_note: self.name }, pluck="pos_closing_entry" ) - if pos_closing_entry: + if pos_closing_entry and pos_closing_entry[0]: msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( frappe.bold("Consolidated Sales Invoice"), get_link_to_form("POS Closing Entry", pos_closing_entry[0]) @@ -572,7 +572,10 @@ class SalesInvoice(SellingController): frappe.throw(msg, title=_("Invalid Account")) if self.customer and account.account_type != "Receivable": - msg = _("Please ensure {} account is a Receivable account.").format(frappe.bold("Debit To")) + " " + msg = _("Please ensure {} account {} is a Receivable account.").format( + frappe.bold("Debit To"), + frappe.bold(self.debit_to) + ) + " " msg += _("Change the account type to Receivable or select a different account.") frappe.throw(msg, title=_("Invalid Account")) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index bab16a494d..994b903b32 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -407,6 +407,22 @@ class AccountsController(TransactionBase): if item_qty != len(get_serial_nos(item.get('serial_no'))): item.set(fieldname, value) + elif ( + ret.get("pricing_rule_removed") + and value is not None + and fieldname + in [ + "discount_percentage", + "discount_amount", + "rate", + "margin_rate_or_amount", + "margin_type", + "remove_free_item", + ] + ): + # reset pricing rule fields if pricing_rule_removed + item.set(fieldname, value) + if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'): item.set('is_fixed_asset', ret.get('is_fixed_asset', 0)) @@ -1318,6 +1334,9 @@ class AccountsController(TransactionBase): payment_schedule['discount_type'] = schedule.discount_type payment_schedule['discount'] = schedule.discount + if not schedule.invoice_portion: + payment_schedule['payment_amount'] = schedule.payment_amount + self.append("payment_schedule", payment_schedule) def set_due_date(self): diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9be5c0d03f..c8e5eddfea 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -215,7 +215,7 @@ class StockController(AccountsController): from `tabStock Ledger Entry` where - voucher_type=%s and voucher_no=%s + voucher_type=%s and voucher_no=%s and is_cancelled = 0 """, (self.doctype, self.name), as_dict=True) for sle in stock_ledger_entries: diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 075e3e38fa..2776628227 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -106,6 +106,9 @@ class calculate_taxes_and_totals(object): self.doc.conversion_rate = flt(self.doc.conversion_rate) def calculate_item_values(self): + if self.doc.get('is_consolidated'): + return + if not self.discount_amount_applied: for item in self.doc.get("items"): self.doc.round_floats_in(item) @@ -647,12 +650,12 @@ class calculate_taxes_and_totals(object): def calculate_change_amount(self): self.doc.change_amount = 0.0 self.doc.base_change_amount = 0.0 + grand_total = self.doc.rounded_total or self.doc.grand_total + base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total if self.doc.doctype == "Sales Invoice" \ - and self.doc.paid_amount > self.doc.grand_total and not self.doc.is_return \ + and self.doc.paid_amount > grand_total and not self.doc.is_return \ and any(d.type == "Cash" for d in self.doc.payments): - grand_total = self.doc.rounded_total or self.doc.grand_total - base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total self.doc.change_amount = flt(self.doc.paid_amount - grand_total + self.doc.write_off_amount, self.doc.precision("change_amount")) diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index 6b85927d3e..6d27f4abef 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -546,7 +546,7 @@ class TestLeaveApplication(unittest.TestCase): from erpnext.hr.utils import allocate_earned_leaves i = 0 while(i<14): - allocate_earned_leaves() + allocate_earned_leaves(ignore_duplicates=True) i += 1 self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6) @@ -554,7 +554,7 @@ class TestLeaveApplication(unittest.TestCase): frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0) i = 0 while(i<6): - allocate_earned_leaves() + allocate_earned_leaves(ignore_duplicates=True) i += 1 self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9) diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 355370f3a4..41a9558deb 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -8,7 +8,7 @@ from math import ceil import frappe from frappe import _, bold from frappe.model.document import Document -from frappe.utils import date_diff, flt, formatdate, get_datetime, getdate +from frappe.utils import date_diff, flt, formatdate, get_datetime, get_last_day, getdate class LeavePolicyAssignment(Document): @@ -108,8 +108,8 @@ class LeavePolicyAssignment(Document): def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining): from erpnext.hr.utils import get_monthly_earned_leave - current_month = get_datetime().month - current_year = get_datetime().year + current_month = get_datetime(frappe.flags.current_date).month or get_datetime().month + current_year = get_datetime(frappe.flags.current_date).year or get_datetime().year from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date") if getdate(date_of_joining) > getdate(from_date): @@ -119,10 +119,14 @@ class LeavePolicyAssignment(Document): from_date_year = get_datetime(from_date).year months_passed = 0 + if current_year == from_date_year and current_month > from_date_month: months_passed = current_month - from_date_month + months_passed = add_current_month_if_applicable(months_passed) + elif current_year > from_date_year: months_passed = (12 - from_date_month) + current_month + months_passed = add_current_month_if_applicable(months_passed) if months_passed > 0: monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated, @@ -134,6 +138,17 @@ class LeavePolicyAssignment(Document): return new_leaves_allocated +def add_current_month_if_applicable(months_passed): + date = getdate(frappe.flags.current_date) or getdate() + last_day_of_month = get_last_day(date) + + # if its the last day of the month, then that month should also be considered + if last_day_of_month == date: + months_passed += 1 + + return months_passed + + @frappe.whitelist() def create_assignment_for_multiple_employees(employees, data): diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py index 3b7f8ec822..8c76ca1cc3 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import add_months, get_first_day, getdate +from frappe.utils import add_months, get_first_day, get_last_day, getdate from erpnext.hr.doctype.leave_application.test_leave_application import ( get_employee, @@ -125,6 +125,121 @@ class TestLeavePolicyAssignment(unittest.TestCase): }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 0) + def test_earned_leave_allocation_for_passed_months(self): + employee = get_employee() + leave_type = create_earned_leave_type("Test Earned Leave") + leave_period = create_leave_period("Test Earned Leave Period", + start_date=get_first_day(add_months(getdate(), -1))) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).insert() + + # Case 1: assignment created one month after the leave period, should allocate 1 leave + frappe.flags.current_date = get_first_day(getdate()) + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 1) + + def test_earned_leave_allocation_for_passed_months_on_month_end(self): + employee = get_employee() + leave_type = create_earned_leave_type("Test Earned Leave") + leave_period = create_leave_period("Test Earned Leave Period", + start_date=get_first_day(add_months(getdate(), -2))) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).insert() + + # Case 2: assignment created on the last day of the leave period's latter month + # should allocate 1 leave for current month even though the month has not ended + # since the daily job might have already executed + frappe.flags.current_date = get_last_day(getdate()) + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + # if the daily job is not completed yet, there is another check present + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + def test_earned_leave_allocation_for_passed_months_with_carry_forwarded_leaves(self): + from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation + + employee = get_employee() + leave_type = create_earned_leave_type("Test Earned Leave") + leave_period = create_leave_period("Test Earned Leave Period", + start_date=get_first_day(add_months(getdate(), -2))) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).insert() + + # initial leave allocation = 5 + leave_allocation = create_leave_allocation( + employee=employee.name, + employee_name=employee.employee_name, + leave_type=leave_type.name, + from_date=add_months(getdate(), -12), + to_date=add_months(getdate(), -3), + new_leaves_allocated=5, + carry_forward=0) + leave_allocation.submit() + + # Case 3: assignment created on the last day of the leave period's latter month with carry forwarding + frappe.flags.current_date = get_last_day(add_months(getdate(), -1)) + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name, + "carry_forward": 1 + } + # carry forwarded leaves = 5, 3 leaves allocated for passed months + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + details = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True) + self.assertEqual(details.new_leaves_allocated, 2) + self.assertEqual(details.unused_leaves, 5) + self.assertEqual(details.total_leaves_allocated, 7) + + # if the daily job is not completed yet, there is another check present + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import is_earned_leave_already_allocated + frappe.flags.current_date = get_last_day(getdate()) + + allocation = frappe.get_doc('Leave Allocation', details.name) + # 1 leave is still pending to be allocated, irrespective of carry forwarded leaves + self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation)) + def tearDown(self): frappe.db.rollback() @@ -138,13 +253,14 @@ def create_earned_leave_type(leave_type): is_earned_leave=1, earned_leave_frequency="Monthly", rounding=0.5, - max_leaves_allowed=6 + is_carry_forward=1 )).insert() -def create_leave_period(name): +def create_leave_period(name, start_date=None): frappe.delete_doc_if_exists("Leave Period", name, force=1) - start_date = get_first_day(getdate()) + if not start_date: + start_date = get_first_day(getdate()) return frappe.get_doc(dict( name=name, diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 0febce1610..7fd3a98e2d 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -237,7 +237,7 @@ def generate_leave_encashment(): create_leave_encashment(leave_allocation=leave_allocation) -def allocate_earned_leaves(): +def allocate_earned_leaves(ignore_duplicates=False): '''Allocate earned leaves to Employees''' e_leave_types = get_earned_leaves() today = getdate() @@ -265,9 +265,9 @@ def allocate_earned_leaves(): from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date): - update_previous_leave_allocation(allocation, annual_allocation, e_leave_type) + update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates) -def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type): +def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False): earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding) allocation = frappe.get_doc('Leave Allocation', allocation.name) @@ -277,9 +277,12 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type new_allocation = e_leave_type.max_leaves_allowed if new_allocation != allocation.total_leaves_allocated: - allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) today_date = today() - create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + + if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation): + allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) + create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + def get_monthly_earned_leave(annual_leaves, frequency, rounding): earned_leaves = 0.0 @@ -297,6 +300,28 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding): return earned_leaves +def is_earned_leave_already_allocated(allocation, annual_allocation): + from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import ( + get_leave_type_details, + ) + + leave_type_details = get_leave_type_details() + date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") + + assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment) + leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type, + annual_allocation, leave_type_details, date_of_joining) + + # exclude carry-forwarded leaves while checking for leave allocation for passed months + num_allocations = allocation.total_leaves_allocated + if allocation.unused_leaves: + num_allocations -= allocation.unused_leaves + + if num_allocations >= leaves_for_passed_months: + return True + return False + + def get_leave_allocations(date, leave_type): return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy from `tabLeave Allocation` diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index a399edda70..67c47efb5b 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -201,6 +201,21 @@ class TestWorkOrder(ERPNextTestCase): self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), cint(bin1_on_start_production.reserved_qty_for_production)) + def test_reserved_qty_for_production_closed(self): + + wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2, + source_warehouse=self.warehouse) + item = wo1.required_items[0].item_code + bin_before = get_bin(item, self.warehouse) + bin_before.update_reserved_qty_for_production() + + make_wo_order_test_record(item="_Test FG Item", qty=2, + source_warehouse=self.warehouse) + close_work_order(wo1.name, "Closed") + + bin_after = get_bin(item, self.warehouse) + self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production) + def test_backflush_qty_for_overpduction_manufacture(self): cancel_stock_entry = [] allow_overproduction("overproduction_percentage_for_work_order", 30) @@ -703,7 +718,8 @@ class TestWorkOrder(ERPNextTestCase): wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse, company=company) - self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture') + stock_entry = frappe.get_doc(make_stock_entry(wo.name, 'Material Transfer for Manufacture')) + self.assertRaises(frappe.ValidationError, stock_entry.save) def test_wo_completion_with_pl_bom(self): from erpnext.manufacturing.doctype.bom.test_bom import ( diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index a86edfa45f..7315249512 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import Case +from frappe.query_builder.functions import Sum from frappe.utils import ( cint, date_diff, @@ -1175,3 +1177,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None): doc.set_item_locations() return doc + +def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float: + """Get total reserved quantity for any item in specified warehouse""" + wo = frappe.qb.DocType("Work Order") + wo_item = frappe.qb.DocType("Work Order Item") + + return ( + frappe.qb + .from_(wo) + .from_(wo_item) + .select(Sum(Case() + .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) + .else_(wo_item.required_qty - wo_item.consumed_qty)) + ) + .where( + (wo_item.item_code == item_code) + & (wo_item.parent == wo.name) + & (wo.docstatus == 1) + & (wo_item.source_warehouse == warehouse) + & (wo.status.notin(["Stopped", "Completed", "Closed"])) + & ((wo_item.required_qty > wo_item.transferred_qty) + | (wo_item.required_qty > wo_item.consumed_qty)) + ) + ).run()[0][0] or 0.0 diff --git a/erpnext/patches.txt b/erpnext/patches.txt index feafecbc04..d300340671 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -350,3 +350,4 @@ erpnext.patches.v14_0.migrate_cost_center_allocations erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template erpnext.patches.v13_0.shopping_cart_to_ecommerce erpnext.patches.v13_0.update_disbursement_account +erpnext.patches.v13_0.update_reserved_qty_closed_wo diff --git a/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py b/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py new file mode 100644 index 0000000000..00926b0924 --- /dev/null +++ b/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py @@ -0,0 +1,28 @@ +import frappe + +from erpnext.stock.utils import get_bin + + +def execute(): + + wo = frappe.qb.DocType("Work Order") + wo_item = frappe.qb.DocType("Work Order Item") + + incorrect_item_wh = ( + frappe.qb + .from_(wo) + .join(wo_item).on(wo.name == wo_item.parent) + .select(wo_item.item_code, wo.source_warehouse).distinct() + .where( + (wo.status == "Closed") + & (wo.docstatus == 1) + & (wo.source_warehouse.notnull()) + ) + ).run() + + for item_code, warehouse in incorrect_item_wh: + if not (item_code and warehouse): + continue + + bin = get_bin(item_code, warehouse) + bin.update_reserved_qty_for_production() diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 3791741663..aa3e2f30d7 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1463,7 +1463,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "item_code": d.item_code, "pricing_rules": d.pricing_rules, "parenttype": d.parenttype, - "parent": d.parent + "parent": d.parent, + "price_list_rate": d.price_list_rate }) } }); @@ -2288,7 +2289,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe () => this.frm.doc.ignore_pricing_rule=1, () => me.ignore_pricing_rule(), () => this.frm.doc.ignore_pricing_rule=0, - () => me.apply_pricing_rule() + () => me.apply_pricing_rule(), + () => this.frm.save() ]); } else { frappe.run_serially([ diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index c34e9d05ce..d2bae65239 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -35,28 +35,9 @@ class Bin(Document): def update_reserved_qty_for_production(self): '''Update qty reserved for production from Production Item tables in open work orders''' + from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production - wo = frappe.qb.DocType("Work Order") - wo_item = frappe.qb.DocType("Work Order Item") - - self.reserved_qty_for_production = ( - frappe.qb - .from_(wo) - .from_(wo_item) - .select(Sum(Case() - .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) - .else_(wo_item.required_qty - wo_item.consumed_qty)) - ) - .where( - (wo_item.item_code == self.item_code) - & (wo_item.parent == wo.name) - & (wo.docstatus == 1) - & (wo_item.source_warehouse == self.warehouse) - & (wo.status.notin(["Stopped", "Completed"])) - & ((wo_item.required_qty > wo_item.transferred_qty) - | (wo_item.required_qty > wo_item.consumed_qty)) - ) - ).run()[0][0] or 0.0 + self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse) self.set_projected_qty() diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index e71cdb37cf..b05f58a982 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -346,7 +346,7 @@ "fieldname": "valuation_method", "fieldtype": "Select", "label": "Valuation Method", - "options": "\nFIFO\nMoving Average" + "options": "\nFIFO\nMoving Average\nLIFO" }, { "depends_on": "is_stock_item", @@ -987,4 +987,4 @@ "states": [], "title_field": "item_name", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a2ef7b42be..782fcf04a5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1115,7 +1115,7 @@ class StockEntry(StockController): self.set_actual_qty() self.update_items_for_process_loss() self.validate_customer_provided_item() - self.calculate_rate_and_amount() + self.calculate_rate_and_amount(raise_error_if_no_rate=False) def set_scrap_items(self): if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]: diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 438ec16096..ec7fb0f4a2 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -99,7 +99,7 @@ "fieldname": "valuation_method", "fieldtype": "Select", "label": "Default Valuation Method", - "options": "FIFO\nMoving Average" + "options": "FIFO\nMoving Average\nLIFO" }, { "description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.", @@ -346,7 +346,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-02-04 15:33:43.692736", + "modified": "2022-02-05 15:33:43.692736", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", 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 48753b0edd..cb35bf75d1 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 @@ -167,7 +167,7 @@ def get_columns(): { "fieldname": "stock_queue", "fieldtype": "Data", - "label": "FIFO Queue", + "label": "FIFO/LIFO Queue", }, { diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 0a7ab4009c..41c4002e3f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -16,7 +16,7 @@ from erpnext.stock.utils import ( get_or_make_bin, get_valuation_method, ) -from erpnext.stock.valuation import FIFOValuation +from erpnext.stock.valuation import FIFOValuation, LIFOValuation class NegativeStockError(frappe.ValidationError): pass @@ -461,7 +461,7 @@ class update_entries_after(object): self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) else: - self.update_fifo_values(sle) + self.update_queue_values(sle) self.wh_data.qty_after_transaction += flt(sle.actual_qty) # rounding as per precision @@ -701,14 +701,18 @@ class update_entries_after(object): sle.voucher_type, sle.voucher_no, self.allow_zero_rate, currency=erpnext.get_company_currency(sle.company), company=sle.company) - def update_fifo_values(self, sle): + def update_queue_values(self, sle): incoming_rate = flt(sle.incoming_rate) actual_qty = flt(sle.actual_qty) outgoing_rate = flt(sle.outgoing_rate) - fifo_queue = FIFOValuation(self.wh_data.stock_queue) + if self.valuation_method == "LIFO": + stock_queue = LIFOValuation(self.wh_data.stock_queue) + else: + stock_queue = FIFOValuation(self.wh_data.stock_queue) + if actual_qty > 0: - fifo_queue.add_stock(qty=actual_qty, rate=incoming_rate) + stock_queue.add_stock(qty=actual_qty, rate=incoming_rate) else: def rate_generator() -> float: allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) @@ -719,11 +723,11 @@ class update_entries_after(object): else: return 0.0 - fifo_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) + stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) - stock_qty, stock_value = fifo_queue.get_total_stock_and_value() + stock_qty, stock_value = stock_queue.get_total_stock_and_value() - self.wh_data.stock_queue = fifo_queue.get_state() + self.wh_data.stock_queue = stock_queue.state self.wh_data.stock_value = stock_value if stock_qty: self.wh_data.valuation_rate = stock_value / stock_qty diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py index 85788bac7f..648d4406ca 100644 --- a/erpnext/stock/tests/test_valuation.py +++ b/erpnext/stock/tests/test_valuation.py @@ -1,16 +1,21 @@ +import json import unittest +import frappe from hypothesis import given from hypothesis import strategies as st -from erpnext.stock.valuation import FIFOValuation, _round_off_if_near_zero +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero +from erpnext.tests.utils import ERPNextTestCase qty_gen = st.floats(min_value=-1e6, max_value=1e6) value_gen = st.floats(min_value=1, max_value=1e6) stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10) -class TestFifoValuation(unittest.TestCase): +class TestFIFOValuation(unittest.TestCase): def setUp(self): self.queue = FIFOValuation([]) @@ -164,3 +169,184 @@ class TestFifoValuation(unittest.TestCase): total_value -= sum(q * r for q, r in consumed) self.assertTotalQty(total_qty) self.assertTotalValue(total_value) + + +class TestLIFOValuation(unittest.TestCase): + + def setUp(self): + self.stack = LIFOValuation([]) + + def tearDown(self): + qty, value = self.stack.get_total_stock_and_value() + self.assertTotalQty(qty) + self.assertTotalValue(value) + + def assertTotalQty(self, qty): + self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4) + + def assertTotalValue(self, value): + self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2) + + def test_simple_addition(self): + self.stack.add_stock(1, 10) + self.assertTotalQty(1) + + def test_merge_new_stock(self): + self.stack.add_stock(1, 10) + self.stack.add_stock(1, 10) + self.assertEqual(self.stack, [[2, 10]]) + + def test_simple_removal(self): + self.stack.add_stock(1, 10) + self.stack.remove_stock(1) + self.assertTotalQty(0) + + def test_adding_negative_stock_keeps_rate(self): + self.stack = LIFOValuation([[-5.0, 100]]) + self.stack.add_stock(1, 10) + self.assertEqual(self.stack, [[-4, 100]]) + + def test_adding_negative_stock_updates_rate(self): + self.stack = LIFOValuation([[-5.0, 100]]) + self.stack.add_stock(6, 10) + self.assertEqual(self.stack, [[1, 10]]) + + def test_rounding_off(self): + self.stack.add_stock(1.0, 1.0) + self.stack.remove_stock(1.0 - 1e-9) + self.assertTotalQty(0) + + def test_lifo_consumption(self): + self.stack.add_stock(10, 10) + self.stack.add_stock(10, 20) + consumed = self.stack.remove_stock(15) + self.assertEqual(consumed, [[10, 20], [5, 10]]) + self.assertTotalQty(5) + + def test_lifo_consumption_going_negative(self): + self.stack.add_stock(10, 10) + self.stack.add_stock(10, 20) + consumed = self.stack.remove_stock(25) + self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]]) + self.assertTotalQty(-5) + + def test_lifo_consumption_multiple(self): + self.stack.add_stock(1, 1) + self.stack.add_stock(2, 2) + consumed = self.stack.remove_stock(1) + self.assertEqual(consumed, [[1, 2]]) + + self.stack.add_stock(3, 3) + consumed = self.stack.remove_stock(4) + self.assertEqual(consumed, [[3, 3], [1, 2]]) + + self.stack.add_stock(4, 4) + consumed = self.stack.remove_stock(5) + self.assertEqual(consumed, [[4, 4], [1, 1]]) + + self.stack.add_stock(5, 5) + consumed = self.stack.remove_stock(5) + self.assertEqual(consumed, [[5, 5]]) + + + @given(stock_queue_generator) + def test_lifo_qty_hypothesis(self, stock_stack): + self.stack = LIFOValuation([]) + total_qty = 0 + + for qty, rate in stock_stack: + if qty == 0: + continue + if qty > 0: + self.stack.add_stock(qty, rate) + total_qty += qty + else: + qty = abs(qty) + consumed = self.stack.remove_stock(qty) + self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}") + total_qty -= qty + self.assertTotalQty(total_qty) + + @given(stock_queue_generator) + def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack): + self.stack = LIFOValuation([]) + total_qty = 0.0 + total_value = 0.0 + + for qty, rate in stock_stack: + # don't allow negative stock + if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1: + continue + if qty > 0: + self.stack.add_stock(qty, rate) + total_qty += qty + total_value += qty * rate + else: + qty = abs(qty) + consumed = self.stack.remove_stock(qty) + self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}") + total_qty -= qty + total_value -= sum(q * r for q, r in consumed) + self.assertTotalQty(total_qty) + self.assertTotalValue(total_value) + +class TestLIFOValuationSLE(ERPNextTestCase): + ITEM_CODE = "_Test LIFO item" + WAREHOUSE = "_Test Warehouse - _TC" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"}) + + def _make_stock_entry(self, qty, rate=None): + kwargs = { + "item_code": self.ITEM_CODE, + "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE, + "rate": rate, + "qty": abs(qty), + } + return make_stock_entry(**kwargs) + + def assertStockQueue(self, se, expected_queue): + sle_name = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"}) + sle = frappe.get_doc("Stock Ledger Entry", sle_name) + + stock_queue = json.loads(sle.stock_queue) + + total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value() + self.assertEqual(sle.qty_after_transaction, total_qty) + self.assertEqual(sle.stock_value, total_value) + + if total_qty > 0: + self.assertEqual(stock_queue, expected_queue) + + + def test_lifo_values(self): + + in1 = self._make_stock_entry(1, 1) + self.assertStockQueue(in1, [[1, 1]]) + + in2 = self._make_stock_entry(2, 2) + self.assertStockQueue(in2, [[1, 1], [2, 2]]) + + out1 = self._make_stock_entry(-1) + self.assertStockQueue(out1, [[1, 1], [1, 2]]) + + in3 = self._make_stock_entry(3, 3) + self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]]) + + out2 = self._make_stock_entry(-4) + self.assertStockQueue(out2, [[1, 1]]) + + in4 = self._make_stock_entry(4, 4) + self.assertStockQueue(in4, [[1, 1], [4,4]]) + + out3 = self._make_stock_entry(-5) + self.assertStockQueue(out3, []) + + in5 = self._make_stock_entry(5, 5) + self.assertStockQueue(in5, [[5, 5]]) + + out5 = self._make_stock_entry(-5) + self.assertStockQueue(out5, []) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 7c63c17ad0..c75c737fc5 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime import erpnext +from erpnext.stock.valuation import FIFOValuation, LIFOValuation class InvalidWarehouseCompany(frappe.ValidationError): pass @@ -228,10 +229,10 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): else: valuation_method = get_valuation_method(args.get("item_code")) previous_sle = get_previous_sle(args) - if valuation_method == 'FIFO': + if valuation_method in ('FIFO', 'LIFO'): if previous_sle: previous_stock_queue = json.loads(previous_sle.get('stock_queue', '[]') or '[]') - in_rate = get_fifo_rate(previous_stock_queue, args.get("qty") or 0) if previous_stock_queue else 0 + in_rate = _get_fifo_lifo_rate(previous_stock_queue, args.get("qty") or 0, valuation_method) if previous_stock_queue else 0 elif valuation_method == 'Moving Average': in_rate = previous_sle.get('valuation_rate') or 0 @@ -261,29 +262,25 @@ def get_valuation_method(item_code): def get_fifo_rate(previous_stock_queue, qty): """get FIFO (average) Rate from Queue""" - if flt(qty) >= 0: - total = sum(f[0] for f in previous_stock_queue) - return sum(flt(f[0]) * flt(f[1]) for f in previous_stock_queue) / flt(total) if total else 0.0 - else: - available_qty_for_outgoing, outgoing_cost = 0, 0 - qty_to_pop = abs(flt(qty)) - while qty_to_pop and previous_stock_queue: - batch = previous_stock_queue[0] - if 0 < batch[0] <= qty_to_pop: - # if batch qty > 0 - # not enough or exactly same qty in current batch, clear batch - available_qty_for_outgoing += flt(batch[0]) - outgoing_cost += flt(batch[0]) * flt(batch[1]) - qty_to_pop -= batch[0] - previous_stock_queue.pop(0) - else: - # all from current batch - available_qty_for_outgoing += flt(qty_to_pop) - outgoing_cost += flt(qty_to_pop) * flt(batch[1]) - batch[0] -= qty_to_pop - qty_to_pop = 0 + return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO") - return outgoing_cost / available_qty_for_outgoing +def get_lifo_rate(previous_stock_queue, qty): + """get LIFO (average) Rate from Queue""" + return _get_fifo_lifo_rate(previous_stock_queue, qty, "LIFO") + + +def _get_fifo_lifo_rate(previous_stock_queue, qty, method): + ValuationKlass = LIFOValuation if method == "LIFO" else FIFOValuation + + stock_queue = ValuationKlass(previous_stock_queue) + if flt(qty) >= 0: + total_qty, total_value = stock_queue.get_total_stock_and_value() + return total_value / total_qty if total_qty else 0.0 + else: + popped_bins = stock_queue.remove_stock(abs(flt(qty))) + + total_qty, total_value = ValuationKlass(popped_bins).get_total_stock_and_value() + return total_value / total_qty if total_qty else 0.0 def get_valid_serial_nos(sr_nos, qty=0, item_code=''): """split serial nos, validate and return list of valid serial nos""" diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py index 45c5083099..ee9477ed74 100644 --- a/erpnext/stock/valuation.py +++ b/erpnext/stock/valuation.py @@ -1,15 +1,54 @@ +from abc import ABC, abstractmethod, abstractproperty from typing import Callable, List, NewType, Optional, Tuple from frappe.utils import flt -FifoBin = NewType("FifoBin", List[float]) +StockBin = NewType("StockBin", List[float]) # [[qty, rate], ...] # Indexes of values inside FIFO bin 2-tuple QTY = 0 RATE = 1 -class FIFOValuation: +class BinWiseValuation(ABC): + + @abstractmethod + def add_stock(self, qty: float, rate: float) -> None: + pass + + @abstractmethod + def remove_stock( + self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None + ) -> List[StockBin]: + pass + + @abstractproperty + def state(self) -> List[StockBin]: + pass + + def get_total_stock_and_value(self) -> Tuple[float, float]: + total_qty = 0.0 + total_value = 0.0 + + for qty, rate in self.state: + total_qty += flt(qty) + total_value += flt(qty) * flt(rate) + + return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) + + def __repr__(self): + return str(self.state) + + def __iter__(self): + return iter(self.state) + + def __eq__(self, other): + if isinstance(other, list): + return self.state == other + return type(self) == type(other) and self.state == other.state + + +class FIFOValuation(BinWiseValuation): """Valuation method where a queue of all the incoming stock is maintained. New stock is added at end of the queue. @@ -24,34 +63,14 @@ class FIFOValuation: # ref: https://docs.python.org/3/reference/datamodel.html#slots __slots__ = ["queue",] - def __init__(self, state: Optional[List[FifoBin]]): - self.queue: List[FifoBin] = state if state is not None else [] + def __init__(self, state: Optional[List[StockBin]]): + self.queue: List[StockBin] = state if state is not None else [] - def __repr__(self): - return str(self.queue) - - def __iter__(self): - return iter(self.queue) - - def __eq__(self, other): - if isinstance(other, list): - return self.queue == other - return self.queue == other.queue - - def get_state(self) -> List[FifoBin]: + @property + def state(self) -> List[StockBin]: """Get current state of queue.""" return self.queue - def get_total_stock_and_value(self) -> Tuple[float, float]: - total_qty = 0.0 - total_value = 0.0 - - for qty, rate in self.queue: - total_qty += flt(qty) - total_value += flt(qty) * flt(rate) - - return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) - def add_stock(self, qty: float, rate: float) -> None: """Update fifo queue with new stock. @@ -78,7 +97,7 @@ class FIFOValuation: def remove_stock( self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None - ) -> List[FifoBin]: + ) -> List[StockBin]: """Remove stock from the queue and return popped bins. args: @@ -136,6 +155,101 @@ class FIFOValuation: return consumed_bins +class LIFOValuation(BinWiseValuation): + """Valuation method where a *stack* of all the incoming stock is maintained. + + New stock is added at top of the stack. + Qty consumption happens on Last In First Out basis. + + Stack is implemented using "bins" of [qty, rate]. + + ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting + Implementation detail: appends and pops both at end of list. + """ + + # specifying the attributes to save resources + # ref: https://docs.python.org/3/reference/datamodel.html#slots + __slots__ = ["stack",] + + def __init__(self, state: Optional[List[StockBin]]): + self.stack: List[StockBin] = state if state is not None else [] + + @property + def state(self) -> List[StockBin]: + """Get current state of stack.""" + return self.stack + + def add_stock(self, qty: float, rate: float) -> None: + """Update lifo stack with new stock. + + args: + qty: new quantity to add + rate: incoming rate of new quantity. + + Behaviour of this is same as FIFO valuation. + """ + if not len(self.stack): + self.stack.append([0, 0]) + + # last row has the same rate, merge new bin. + if self.stack[-1][RATE] == rate: + self.stack[-1][QTY] += qty + else: + # Item has a positive balance qty, add new entry + if self.stack[-1][QTY] > 0: + self.stack.append([qty, rate]) + else: # negative balance qty + qty = self.stack[-1][QTY] + qty + if qty > 0: # new balance qty is positive + self.stack[-1] = [qty, rate] + else: # new balance qty is still negative, maintain same rate + self.stack[-1][QTY] = qty + + + def remove_stock( + self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None + ) -> List[StockBin]: + """Remove stock from the stack and return popped bins. + + args: + qty: quantity to remove + rate: outgoing rate - ignored. Kept for backwards compatibility. + rate_generator: function to be called if stack is not found and rate is required. + """ + if not rate_generator: + rate_generator = lambda : 0.0 # noqa + + consumed_bins = [] + while qty: + if not len(self.stack): + # rely on rate generator. + self.stack.append([0, rate_generator()]) + + # start at the end. + index = -1 + + stock_bin = self.stack[index] + if qty >= stock_bin[QTY]: + # consume current bin + qty = _round_off_if_near_zero(qty - stock_bin[QTY]) + to_consume = self.stack.pop(index) + consumed_bins.append(list(to_consume)) + + if not self.stack and qty: + # stock finished, qty still remains to be withdrawn + # negative stock, keep in as a negative bin + self.stack.append([-qty, outgoing_rate or stock_bin[RATE]]) + consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]]) + break + else: + # qty found in current bin consume it and exit + stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty) + consumed_bins.append([qty, stock_bin[RATE]]) + qty = 0 + + return consumed_bins + + def _round_off_if_near_zero(number: float, precision: int = 7) -> float: """Rounds off the number to zero only if number is close to zero for decimal specified in precision. Precision defaults to 7. diff --git a/erpnext/tests/test_point_of_sale.py b/erpnext/tests/test_point_of_sale.py index df2dc8b99a..3299c8885f 100644 --- a/erpnext/tests/test_point_of_sale.py +++ b/erpnext/tests/test_point_of_sale.py @@ -1,15 +1,25 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt +import unittest + +import frappe from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile from erpnext.selling.page.point_of_sale.point_of_sale import get_items from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.tests.utils import ERPNextTestCase -class TestPointOfSale(ERPNextTestCase): +class TestPointOfSale(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + frappe.db.savepoint('before_test_point_of_sale') + + @classmethod + def tearDownClass(cls) -> None: + frappe.db.rollback(save_point='before_test_point_of_sale') + def test_item_search(self): """ Test Stock and Service Item Search.