fix: allow direct drop-ship on Purchase Orders without Sales Order (#54930)

This commit is contained in:
Raffael Meyer
2026-05-20 18:03:21 +02:00
committed by GitHub
parent 3084e3654c
commit 8845be9419
6 changed files with 100 additions and 13 deletions

View File

@@ -333,7 +333,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
if (is_drop_ship && !["Completed", "Delivered"].includes(doc.status)) { if (is_drop_ship && !["Completed", "Delivered"].includes(doc.status)) {
this.frm.add_custom_button( this.frm.add_custom_button(
__("Deliver (Dropship)"), __("Deliver (Dropship)"),
this.delivered_by_supplier.bind(this), this.update_dropship_delivered_qty.bind(this),
__("Status") __("Status")
); );
@@ -698,7 +698,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
this.frm.cscript.update_status("Close", "Closed"); this.frm.cscript.update_status("Close", "Closed");
} }
delivered_by_supplier() { update_dropship_delivered_qty() {
const data = this.frm.doc.items const data = this.frm.doc.items
.filter((item) => item.delivered_by_supplier == 1) .filter((item) => item.delivered_by_supplier == 1)
.map((item) => { .map((item) => {

View File

@@ -664,12 +664,21 @@ class PurchaseOrder(BuyingController):
if not self.is_against_so(): if not self.is_against_so():
return return
for item in removed_items: for item in removed_items:
sales_order_item = item.get("sales_order_item")
if not sales_order_item:
continue
prev_ordered_qty = flt( 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( 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): def auto_create_subcontracting_order(self):

View File

@@ -1436,6 +1436,88 @@ class TestPurchaseOrder(ERPNextTestSuite):
pi2 = make_pi_from_po(po.name) pi2 = make_pi_from_po(po.name)
self.assertEqual(len(pi2.items), 2) 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(): def create_po_for_sc_testing():
from erpnext.controllers.tests.test_subcontracting_controller import ( from erpnext.controllers.tests.test_subcontracting_controller import (

View File

@@ -539,12 +539,10 @@
}, },
{ {
"default": "0", "default": "0",
"depends_on": "delivered_by_supplier",
"fieldname": "delivered_by_supplier", "fieldname": "delivered_by_supplier",
"fieldtype": "Check", "fieldtype": "Check",
"label": "To be Delivered to Customer", "label": "To be Delivered to Customer",
"print_hide": 1, "print_hide": 1
"read_only": 1
}, },
{ {
"depends_on": "eval:doc.against_blanket_order", "depends_on": "eval:doc.against_blanket_order",
@@ -941,7 +939,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-05-14 12:16:16.192936", "modified": "2026-05-20 00:50:16.192936",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Purchase Order Item", "name": "Purchase Order Item",

View File

@@ -648,7 +648,7 @@
}, },
{ {
"default": "0", "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", "fieldname": "delivered_by_supplier",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Delivered by Supplier (Drop Ship)", "label": "Delivered by Supplier (Drop Ship)",
@@ -1077,7 +1077,7 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2026-04-28 17:31:47.613279", "modified": "2026-05-14 02:09:33.455292",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Item", "name": "Item",

View File

@@ -493,9 +493,7 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It
"discount_percentage": 0.0, "discount_percentage": 0.0,
"discount_amount": flt(ctx.discount_amount) or 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, "update_stock": ctx.update_stock if ctx.doctype in ["Sales Invoice", "Purchase Invoice"] else 0,
"delivered_by_supplier": item.delivered_by_supplier "delivered_by_supplier": item.delivered_by_supplier,
if ctx.doctype in ["Sales Order", "Sales Invoice"]
else 0,
"is_fixed_asset": item.is_fixed_asset, "is_fixed_asset": item.is_fixed_asset,
"last_purchase_rate": item.last_purchase_rate if ctx.doctype in ["Purchase Order"] else 0, "last_purchase_rate": item.last_purchase_rate if ctx.doctype in ["Purchase Order"] else 0,
"transaction_date": ctx.transaction_date, "transaction_date": ctx.transaction_date,