From 2d96a625303ca9b7ad629f3b8be3ea241aa83810 Mon Sep 17 00:00:00 2001 From: marination <25857446+marination@users.noreply.github.com> Date: Fri, 14 Mar 2025 18:54:11 +0100 Subject: [PATCH] test: Sales Order + fix: Mapping of Items from Quotation & SO (cherry picked from commit 55981c8358f1d9f503ceec918f217c209896180f) # Conflicts: # erpnext/selling/doctype/sales_order/test_sales_order.py --- .../purchase_order/test_purchase_order.py | 2 +- .../selling/doctype/quotation/quotation.py | 8 +- .../doctype/sales_order/sales_order.js | 8 +- .../doctype/sales_order/sales_order.py | 9 +- .../doctype/sales_order/test_sales_order.py | 113 ++++++++++++++++++ 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 59143ed564e..74b23434b2f 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1234,7 +1234,7 @@ class TestPurchaseOrder(FrappeTestCase): po.reload() self.assertEqual(po.items[0].received_qty, 5) - # PO still has qty 0, so billed % should be unset + # PO still has qty 0, so received % should be unset self.assertFalse(po.per_received) self.assertEqual(po.status, "To Receive and Bill") diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index a4f79c1683d..ff33cc43f17 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -380,6 +380,8 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): as_list=1, ) ) + # 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", [])] @@ -427,14 +429,12 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty 3. If selections: Simple row: Map if adequate qty """ - # has_unit_price_items = 0 is accepted as the qty uncertain for some items - has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items") - 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_qty = balance_qty or has_unit_price_items + has_qty: bool = (balance_qty > 0) or has_unit_price_items if not selected_rows: return not item.is_alternative diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index ca3d2ac824b..0c4cd43ec36 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -597,10 +597,12 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } if (doc.status !== "Closed") { if (doc.status !== "On Hold") { + const items_are_deliverable = this.frm.doc.items.some( + (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty) + ); allow_delivery = - this.frm.doc.items.some( - (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty) - ) && !this.frm.doc.skip_delivery_note; + (this.frm.doc.has_unit_price_items || items_are_deliverable) && + !this.frm.doc.skip_delivery_note; if (this.frm.has_perm("submit")) { if (flt(doc.per_delivered) < 100 || flt(doc.per_billed) < 100) { diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 54ee5f0d21d..f1b405404fd 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -962,6 +962,9 @@ 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) @@ -1008,12 +1011,14 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates: return False - return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1 + return ( + (abs(doc.delivered_qty) < abs(doc.qty)) or has_unit_price_items + ) 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) + target.qty = flt(source.qty) - flt(source.delivered_qty) if not has_unit_price_items else 0 item = get_item_defaults(target.item_code, source_parent.company) item_group = get_item_group_defaults(target.item_code, source_parent.company) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 82a8535a66d..177e5083cc5 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -6,7 +6,11 @@ import json import frappe import frappe.permissions from frappe.core.doctype.user_permission.test_user_permission import create_user +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings +======= +from frappe.tests import IntegrationTestCase, change_settings +>>>>>>> 55981c8358 (test: Sales Order + fix: Mapping of Items from Quotation & SO) from frappe.utils import add_days, flt, getdate, nowdate, today from erpnext.accounts.test.accounts_mixin import AccountsTestMixin @@ -86,6 +90,39 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): ) update_child_qty_rate("Sales Order", trans_item, so.name) +<<<<<<< HEAD +======= + def test_sales_order_qty(self): + so = make_sales_order(qty=1, do_not_save=True) + + # NonNegativeError with qty=-1 + so.append( + "items", + { + "item_code": "_Test Item", + "qty": -1, + "rate": 10, + }, + ) + self.assertRaises(frappe.NonNegativeError, so.save) + + # InvalidQtyError with qty=0 + so.items[1].qty = 0 + self.assertRaises(InvalidQtyError, so.save) + + # No error with qty=1 + so.items[1].qty = 1 + so.save() + self.assertEqual(so.items[0].qty, 1) + + def test_sales_order_zero_qty(self): + po = make_sales_order(qty=0, do_not_save=True) + + with change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}): + po.save() + self.assertEqual(po.items[0].qty, 0) + +>>>>>>> 55981c8358 (test: Sales Order + fix: Mapping of Items from Quotation & SO) def test_make_material_request(self): so = make_sales_order(do_not_submit=True) @@ -2192,6 +2229,82 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): po.submit() self.assertEqual(po.taxes[0].tax_amount, 2) + @IntegrationTestCase.change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}) + def test_deliver_zero_qty_purchase_order(self): + """ + 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 = make_sales_order(qty=0) + dn = make_delivery_note(so.name) + + self.assertEqual(dn.items[0].qty, 0) + dn.items[0].qty = 5 + dn.submit() + + 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 + first_item_of_so = so.items[0] + trans_item = json.dumps( + [ + { + "item_code": first_item_of_so.item_code, + "rate": first_item_of_so.rate, + "qty": 10, + "docname": first_item_of_so.name, + } + ] + ) + update_child_qty_rate("Sales Order", trans_item, so.name) + + # SO should be updated to 100% delivered + so.reload() + self.assertEqual(so.items[0].qty, 10) + self.assertEqual(so.per_delivered, 100.0) + self.assertEqual(so.status, "To Bill") + + @IntegrationTestCase.change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}) + def test_bill_zero_qty_sales_order(self): + so = make_sales_order(qty=0) + + self.assertEqual(so.grand_total, 0) + self.assertFalse(so.per_billed) + self.assertEqual(so.items[0].qty, 0) + self.assertEqual(so.items[0].rate, 100) + + si = make_sales_invoice(so.name) + self.assertEqual(si.items[0].qty, 0) + self.assertEqual(si.items[0].rate, 100) + + 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) + # SO still has qty 0, so billed % should be unset + self.assertFalse(so.per_billed) + self.assertEqual(so.status, "To Deliver and Bill") + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings")