From 55981c8358f1d9f503ceec918f217c209896180f 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 --- .../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 | 85 ++++++++++++++++++- 5 files changed, 101 insertions(+), 11 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 71466e57c27..7c59b7f271b 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1262,7 +1262,7 @@ class TestPurchaseOrder(IntegrationTestCase): 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 3fef12aea7f..3fd277c45ed 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -381,6 +381,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", [])] @@ -428,14 +430,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 a62c4715f38..638568b4279 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -618,10 +618,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 219e679f3e3..0b9ab56200e 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -967,6 +967,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) @@ -1016,12 +1019,14 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): if cstr(doc.delivery_date) > frappe.flags.args.until_delivery_date: 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 941af58d380..25a9b3f295a 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -7,7 +7,7 @@ from unittest.mock import patch import frappe import frappe.permissions from frappe.core.doctype.user_permission.test_user_permission import create_user -from frappe.tests import IntegrationTestCase +from frappe.tests import IntegrationTestCase, change_settings from frappe.utils import add_days, flt, getdate, nowdate, today from erpnext.accounts.test.accounts_mixin import AccountsTestMixin @@ -109,6 +109,13 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): 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) + def test_make_material_request(self): so = make_sales_order(do_not_submit=True) @@ -2321,6 +2328,82 @@ class TestSalesOrder(AccountsTestMixin, IntegrationTestCase): sre_doc.reload() self.assertTrue(sre_doc.status == "Delivered") + @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")