Merge pull request #29701 from nextchamp-saqib/consolidation-round-off-err
fix(pos): incorrect grand_total in case of inclusive taxes on item
diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
index ba751c0..cf8affd 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 @@
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/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
index ac96b04..933fda8 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
@@ -249,13 +249,17 @@
"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 @@
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 @@
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 @@
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 @@
item_details.applied_on_items = ','.join(items)
item_details.pricing_rules = ''
+ item_details.pricing_rule_removed = True
return item_details
@@ -432,9 +442,12 @@
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 968137e..8338a5b0 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 @@
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 @@
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/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 8be88b8..994b903 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -407,6 +407,22 @@
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))
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 7697801..67c47ef 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 @@
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)
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index a86edfa..7315249 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -8,6 +8,8 @@
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 @@
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 feafecb..d300340 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -350,3 +350,4 @@
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 0000000..00926b0
--- /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 ab3e802..aa3e2f3 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -1463,7 +1463,8 @@
"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
})
}
});
diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py
index c34e9d0..d2bae65 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -35,28 +35,9 @@
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 e71cdb3..b05f58a 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_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index 438ec16..ec7fb0f 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 48753b0..cb35bf7 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 @@
{
"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 0a7ab40..41c4002 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -16,7 +16,7 @@
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 @@
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 @@
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 @@
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 85788ba..648d440 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 @@
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 7c63c17..c75c737 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -9,6 +9,7 @@
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 @@
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_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 45c5083..ee9477e 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 @@
# 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 @@
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 @@
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.