From 8845be94199a3e5426cf30d578bee2b4f3f5708a Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Wed, 20 May 2026 18:03:21 +0200 Subject: [PATCH] fix: allow direct drop-ship on Purchase Orders without Sales Order (#54930) --- .../doctype/purchase_order/purchase_order.js | 4 +- .../doctype/purchase_order/purchase_order.py | 13 ++- .../purchase_order/test_purchase_order.py | 82 +++++++++++++++++++ .../purchase_order_item.json | 6 +- erpnext/stock/doctype/item/item.json | 4 +- erpnext/stock/get_item_details.py | 4 +- 6 files changed, 100 insertions(+), 13 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 54b9a2a0ca1..85c159ed491 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -333,7 +333,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( if (is_drop_ship && !["Completed", "Delivered"].includes(doc.status)) { this.frm.add_custom_button( __("Deliver (Dropship)"), - this.delivered_by_supplier.bind(this), + this.update_dropship_delivered_qty.bind(this), __("Status") ); @@ -698,7 +698,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( this.frm.cscript.update_status("Close", "Closed"); } - delivered_by_supplier() { + update_dropship_delivered_qty() { const data = this.frm.doc.items .filter((item) => item.delivered_by_supplier == 1) .map((item) => { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 6a621fb6774..ba928c6dbb7 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -664,12 +664,21 @@ class PurchaseOrder(BuyingController): if not self.is_against_so(): return for item in removed_items: + sales_order_item = item.get("sales_order_item") + if not sales_order_item: + continue + prev_ordered_qty = flt( - frappe.get_cached_value("Sales Order Item", item.get("sales_order_item"), "ordered_qty") + frappe.get_cached_value("Sales Order Item", sales_order_item, "ordered_qty") + ) + # `Sales Order Item.ordered_qty` is tracked in stock UOM (see status_updater); + # use the row's stock_qty so PO UOMs that differ from stock UOM decrement correctly. + qty_in_stock_uom = flt(item.get("stock_qty")) or flt(item.qty) * flt( + item.get("conversion_factor") or 1 ) frappe.db.set_value( - "Sales Order Item", item.get("sales_order_item"), "ordered_qty", prev_ordered_qty - item.qty + "Sales Order Item", sales_order_item, "ordered_qty", prev_ordered_qty - qty_in_stock_uom ) def auto_create_subcontracting_order(self): diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index e99bc94b8e7..c361e66229e 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1436,6 +1436,88 @@ class TestPurchaseOrder(ERPNextTestSuite): pi2 = make_pi_from_po(po.name) self.assertEqual(len(pi2.items), 2) + def test_get_item_details_propagates_drop_ship_flag_to_po(self): + """`get_item_details` should propagate the Item master's + `delivered_by_supplier` flag to Purchase Orders, not only to Sales + Orders/Invoices, so that POs can be created as drop-ship directly + (via the standard item lookup the form uses) without going through + the Sales Order → Purchase Order mapping pipeline. + """ + from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details + + item = make_item("_Test Drop Ship From Master", {"is_stock_item": 1, "delivered_by_supplier": 1}) + + ctx = ItemDetailsCtx( + { + "item_code": item.item_code, + "doctype": "Purchase Order", + "company": "_Test Company", + "supplier": "_Test Supplier", + "transaction_date": nowdate(), + "currency": "INR", + "conversion_rate": 1.0, + "buying_price_list": "Standard Buying", + "price_list_currency": "INR", + "plc_conversion_rate": 1.0, + "qty": 1, + } + ) + + details = get_item_details(ctx, frappe.new_doc("Purchase Order")) + self.assertEqual(details.get("delivered_by_supplier"), 1) + + def test_drop_ship_po_allows_non_company_shipping_address_without_so(self): + """A PO with a drop-ship item should save with a non-company shipping + address even when there is no linked Sales Order. + Regression test for https://github.com/frappe/erpnext/issues/51629. + """ + from erpnext.crm.doctype.prospect.test_prospect import make_address + + item = make_item("_Test Drop Ship Direct PO", {"is_stock_item": 1, "delivered_by_supplier": 1}) + + customer_shipping = make_address( + address_title="Drop Ship Direct PO", address_type="Shipping", address_line1="1" + ) + customer_shipping.append("links", {"link_doctype": "Customer", "link_name": "_Test Customer"}) + customer_shipping.save() + + po = create_purchase_order(item=item.item_code, qty=1, do_not_save=True) + # In the UI, `get_item_details` propagates the master flag to the row when + # the item is added; here we simulate that step explicitly. + po.items[0].delivered_by_supplier = 1 + po.items[0].warehouse = "" + po.shipping_address = customer_shipping.name + po.save() + + self.assertEqual(po.items[0].delivered_by_supplier, 1) + self.assertFalse(po.items[0].warehouse) + self.assertEqual(po.shipping_address, customer_shipping.name) + + def test_drop_ship_flag_overridable_per_po_line(self): + """The drop-ship default from the Item master should be overridable + on individual PO lines (e.g. ordering a normally drop-shipped item + into the own warehouse for samples or stock). + """ + item = make_item("_Test Drop Ship Override", {"is_stock_item": 1, "delivered_by_supplier": 1}) + + po = create_purchase_order(item=item.item_code, qty=1, do_not_save=True) + po.items[0].delivered_by_supplier = 0 + po.save() + + self.assertEqual(po.items[0].delivered_by_supplier, 0) + self.assertEqual(po.items[0].warehouse, "_Test Warehouse - _TC") + + def test_remove_unlinked_item_from_mixed_po_does_not_crash(self): + """In a PO that mixes SO-linked and freely-added items, removing an + item that has no `sales_order_item` via Update Items must not crash + on the missing reference. + """ + po = create_purchase_order(do_not_submit=True) + # Force the SO codepath without needing a real linked Sales Order: + po.items[0].sales_order = "DUMMY-SO" + + po.update_ordered_qty_in_so_for_removed_items([frappe._dict({"sales_order_item": None, "qty": 1})]) + def create_po_for_sc_testing(): from erpnext.controllers.tests.test_subcontracting_controller import ( diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 2479a00e2de..8db422fee7c 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -539,12 +539,10 @@ }, { "default": "0", - "depends_on": "delivered_by_supplier", "fieldname": "delivered_by_supplier", "fieldtype": "Check", "label": "To be Delivered to Customer", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "depends_on": "eval:doc.against_blanket_order", @@ -941,7 +939,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-14 12:16:16.192936", + "modified": "2026-05-20 00:50:16.192936", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 1f2581a4981..699379d3501 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -648,7 +648,7 @@ }, { "default": "0", - "description": "Enable for drop shipping - supplier delivers directly to the customer without passing through your warehouse.", + "description": "If checked, this item is treated as drop-shipped by default in Sales Orders, Sales Invoices and Purchase Orders. The flag can be overridden on each transaction line.", "fieldname": "delivered_by_supplier", "fieldtype": "Check", "label": "Delivered by Supplier (Drop Ship)", @@ -1077,7 +1077,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-04-28 17:31:47.613279", + "modified": "2026-05-14 02:09:33.455292", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 4e9126c40c1..d40511d5e2c 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -493,9 +493,7 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It "discount_percentage": 0.0, "discount_amount": flt(ctx.discount_amount) or 0.0, "update_stock": ctx.update_stock if ctx.doctype in ["Sales Invoice", "Purchase Invoice"] else 0, - "delivered_by_supplier": item.delivered_by_supplier - if ctx.doctype in ["Sales Order", "Sales Invoice"] - else 0, + "delivered_by_supplier": item.delivered_by_supplier, "is_fixed_asset": item.is_fixed_asset, "last_purchase_rate": item.last_purchase_rate if ctx.doctype in ["Purchase Order"] else 0, "transaction_date": ctx.transaction_date,