fix: Treat rows as Unit Price rows only until the qty is 0

- The unit price check should depend on the row qty being 0
- Once the row ceases to be 0, it is treated as an ordinary row
- test: PO, SO and Quotation
This commit is contained in:
marination
2025-03-17 19:08:16 +01:00
parent 55981c8358
commit 0447c7be0a
6 changed files with 113 additions and 55 deletions

View File

@@ -747,8 +747,11 @@ def set_missing_values(source, target):
def make_purchase_receipt(source_name, target_doc=None):
has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) - flt(obj.received_qty) if not has_unit_price_items else 0
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
@@ -780,7 +783,7 @@ def make_purchase_receipt(source_name, target_doc=None):
},
"postprocess": update_item,
"condition": lambda doc: (
abs(doc.received_qty) < abs(doc.qty) if not has_unit_price_items else True
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
)
and doc.delivered_by_supplier != 1,
},

View File

@@ -1262,18 +1262,6 @@ class TestPurchaseOrder(IntegrationTestCase):
po.reload()
self.assertEqual(po.items[0].received_qty, 5)
# PO still has qty 0, so received % should be unset
self.assertFalse(po.per_received)
self.assertEqual(po.status, "To Receive and Bill")
# Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty
pr2 = make_purchase_receipt(po.name)
self.assertEqual(pr2.items[0].qty, 0)
pr2.items[0].qty = 5
pr2.submit()
po.reload()
self.assertEqual(po.items[0].received_qty, 10)
self.assertFalse(po.per_received)
self.assertEqual(po.status, "To Receive and Bill")
@@ -1291,9 +1279,19 @@ class TestPurchaseOrder(IntegrationTestCase):
)
update_child_qty_rate("Purchase Order", trans_item, po.name)
# Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty
pr2 = make_purchase_receipt(po.name)
po.reload()
self.assertEqual(po.items[0].qty, 10)
self.assertEqual(pr2.items[0].qty, 5)
pr2.submit()
# PO should be updated to 100% received
po.reload()
self.assertEqual(po.items[0].qty, 10)
self.assertEqual(po.items[0].received_qty, 10)
self.assertEqual(po.per_received, 100.0)
self.assertEqual(po.status, "To Bill")

View File

@@ -381,10 +381,14 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
as_list=1,
)
)
selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
# 0 qty is accepted, as the qty uncertain for some items
has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items")
selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
def is_unit_price_row(source) -> bool:
return has_unit_price_items and source.qty == 0
def set_missing_values(source, target):
if customer:
@@ -414,7 +418,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
target.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0)
balance_qty = obj.qty if is_unit_price_row(obj) else obj.qty - ordered_items.get(obj.item_code, 0.0)
target.qty = balance_qty if balance_qty > 0 else 0
target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
@@ -428,23 +432,22 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
Row mapping from Quotation to Sales order:
1. If no selections, map all non-alternative rows (that sum up to the grand total)
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
3. If selections: Simple row: Map if adequate qty
3. If no selections: Simple row: Map if adequate qty
"""
balance_qty = item.qty - ordered_items.get(item.item_code, 0.0)
if balance_qty <= 0 and not has_unit_price_items:
# False if qty <=0 in a 'normal' scenario
return False
has_valid_qty: bool = (balance_qty > 0) or is_unit_price_row(item)
has_qty: bool = (balance_qty > 0) or has_unit_price_items
if not has_valid_qty:
return False
if not selected_rows:
return not item.is_alternative
if selected_rows and (item.is_alternative or item.has_alternative_item):
return (item.name in selected_rows) and has_qty
return item.name in selected_rows
# Simple row
return has_qty
return True
doclist = get_mapped_doc(
"Quotation",

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings
from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.controllers.accounts_controller import InvalidQtyError
@@ -30,6 +30,15 @@ class TestQuotation(IntegrationTestCase):
qo.save()
self.assertEqual(qo.items[0].qty, 1)
def test_quotation_zero_qty(self):
"""
Test if Quote with zero qty (Unit Price Item) is conditionally allowed.
"""
qo = make_quotation(qty=0, do_not_save=True)
with change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1}):
qo.save()
self.assertEqual(qo.items[0].qty, 0)
def test_make_quotation_without_terms(self):
quotation = make_quotation(do_not_save=1)
self.assertFalse(quotation.get("payment_schedule"))
@@ -784,6 +793,39 @@ class TestQuotation(IntegrationTestCase):
self.assertEqual(quotation.rounding_adjustment, 0)
self.assertEqual(quotation.rounded_total, 0)
@IntegrationTestCase.change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1})
def test_so_from_zero_qty_quotation(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
make_item("_Test Item 2", {"is_stock_item": 1})
quotation = make_quotation(qty=0, do_not_save=1)
quotation.append("items", {"item_code": "_Test Item 2", "qty": 10, "rate": 100})
quotation.submit()
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = nowdate()
self.assertEqual(sales_order.items[0].qty, 0)
self.assertEqual(sales_order.items[1].qty, 10)
sales_order.items[0].qty = 10
sales_order.items[1].qty = 5
sales_order.submit()
quotation.reload()
self.assertEqual(quotation.status, "Partially Ordered")
sales_order_2 = make_sales_order(quotation.name)
sales_order_2.delivery_date = nowdate()
self.assertEqual(sales_order_2.items[0].qty, 0)
self.assertEqual(sales_order_2.items[1].qty, 5)
del sales_order_2.items[0]
sales_order_2.submit()
quotation.reload()
self.assertEqual(quotation.status, "Ordered")
def enable_calculate_bundle_price(enable=1):
selling_settings = frappe.get_doc("Selling Settings")

View File

@@ -967,9 +967,6 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
kwargs = frappe._dict(kwargs)
# 0 qty is accepted, as the qty is uncertain for some items
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
sre_details = {}
if kwargs.for_reserved_stock:
sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name)
@@ -980,6 +977,12 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
}
# 0 qty is accepted, as the qty is uncertain for some items
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def set_missing_values(source, target):
if kwargs.get("ignore_pricing_rule"):
# Skip pricing rule when the dn is creating from the pick list
@@ -1020,13 +1023,15 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
return False
return (
(abs(doc.delivered_qty) < abs(doc.qty)) or has_unit_price_items
(abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)
) and doc.delivered_by_supplier != 1
def update_item(source, target, source_parent):
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
target.qty = flt(source.qty) - flt(source.delivered_qty) if not has_unit_price_items else 0
target.qty = (
flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty)
)
item = get_item_defaults(target.item_code, source_parent.company)
item_group = get_item_group_defaults(target.item_code, source_parent.company)
@@ -1109,6 +1114,12 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
@frappe.whitelist()
def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
# 0 qty is accepted, as the qty is uncertain for some items
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def postprocess(source, target):
set_missing_values(source, target)
# Get the advance paid Journal Entries in Sales Invoice Advance
@@ -1150,7 +1161,7 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
target.qty = (
target.amount / flt(source.rate)
if (source.rate and source.billed_amt)
else source.qty - source.returned_qty
else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
)
if source_parent.project:
@@ -1163,8 +1174,6 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
if cost_center:
target.cost_center = cost_center
# has_unit_price_items = 0 is accepted as the qty uncertain for some items
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
doclist = get_mapped_doc(
"Sales Order",
source_name,
@@ -1186,9 +1195,10 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
},
"postprocess": update_item,
"condition": lambda doc: (
doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
)
or has_unit_price_items,
True
if is_unit_price_row(doc)
else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
),
},
"Sales Taxes and Charges": {
"doctype": "Sales Taxes and Charges",

View File

@@ -1995,7 +1995,12 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested")
pr = make_payment_request(
dt=so.doctype, dn=so.name, order_type="Shopping Cart", submit_doc=True, return_doc=True
dt=so.doctype,
dn=so.name,
order_type="Shopping Cart",
submit_doc=True,
return_doc=True,
mute_email=True,
)
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested")
@@ -2023,7 +2028,9 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
so = make_sales_order(qty=1, rate=100)
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested")
pr = make_payment_request(dt=so.doctype, dn=so.name, submit_doc=True, return_doc=True)
pr = make_payment_request(
dt=so.doctype, dn=so.name, submit_doc=True, return_doc=True, mute_email=True
)
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested")
pe = get_payment_entry(so.doctype, so.name).save().submit()
@@ -2333,7 +2340,7 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
"""
Test the flow of a Unit Price SO and DN creation against it until completion.
Flow:
SO Qty 0 -> Deliver +5 -> Deliver +5 -> Update SO Qty +10 -> SO is 100% delivered
SO Qty 0 -> Deliver +5 -> Update SO Qty +10 -> Deliver +5 -> SO is 100% delivered
"""
so = make_sales_order(qty=0)
dn = make_delivery_note(so.name)
@@ -2342,24 +2349,13 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
dn.items[0].qty = 5
dn.submit()
# Test SO impact after DN
so.reload()
self.assertEqual(so.items[0].delivered_qty, 5)
# SO still has qty 0, so delivered % should be unset
self.assertFalse(so.per_delivered)
self.assertEqual(so.status, "To Deliver and Bill")
# Test: DN can be made against SO as long SO qty is 0 OR SO qty > delivered qty
dn2 = make_delivery_note(so.name)
self.assertEqual(dn2.items[0].qty, 0)
dn2.items[0].qty = 5
dn2.submit()
so.reload()
self.assertEqual(so.items[0].delivered_qty, 10)
self.assertFalse(so.per_delivered)
self.assertEqual(so.status, "To Deliver and Bill")
# Update SO Item Qty to 10 after delivery of items
# Update SO Qty to final qty
first_item_of_so = so.items[0]
trans_item = json.dumps(
[
@@ -2373,9 +2369,17 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
)
update_child_qty_rate("Sales Order", trans_item, so.name)
# SO should be updated to 100% delivered
# Test: DN maps pending qty from SO
dn2 = make_delivery_note(so.name)
so.reload()
self.assertEqual(so.items[0].qty, 10)
self.assertEqual(dn2.items[0].qty, 5)
dn2.submit()
so.reload()
self.assertEqual(so.items[0].delivered_qty, 10)
self.assertEqual(so.per_delivered, 100.0)
self.assertEqual(so.status, "To Bill")
@@ -2395,11 +2399,9 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase):
si.items[0].qty = 5
si.submit()
self.assertEqual(si.grand_total, 500)
so.reload()
self.assertEqual(so.items[0].amount, 0)
self.assertEqual(so.items[0].billed_amt, 500)
self.assertEqual(so.items[0].billed_amt, si.grand_total)
# SO still has qty 0, so billed % should be unset
self.assertFalse(so.per_billed)
self.assertEqual(so.status, "To Deliver and Bill")