fix(stock): update pick list status for product bundles (#58306)

This commit is contained in:
Mihir Kandoi
2026-08-19 20:52:47 +05:30
committed by GitHub
parent deeb787f57
commit 696f0df811
4 changed files with 221 additions and 5 deletions

View File

@@ -463,7 +463,6 @@ class DeliveryNote(SellingController):
def on_submit(self):
self.validate_packed_qty()
self.update_pick_list_status()
# Check for Approving Authority
frappe.get_cached_doc("Authorization Control").validate_approving_authority(
@@ -472,6 +471,7 @@ class DeliveryNote(SellingController):
# update delivered qty in sales order
self.update_prevdoc_status()
self.update_pick_list_status()
self.update_billing_status()
if not self.is_return:

View File

@@ -280,7 +280,6 @@ def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_orde
target_bundle_item.qty = pick_list._compute_picked_qty_for_bundle(
so_row, product_bundle_qty_map[value.item_code]
)
target_bundle_item.pick_list_item = value.pick_list_item
target_bundle_item.against_pick_list = pick_list.name
update_child_item(sales_order_item, target_bundle_item, target_doc)

View File

@@ -897,8 +897,7 @@ class PickList(TransactionBase):
return query.run(as_dict=True)
def _get_product_bundles(self) -> dict[str, str]:
# Dict[so_item_row: item_code]
def _get_product_bundles(self) -> dict[str, frappe._dict]:
product_bundles = {}
for item in self.locations:
if not item.product_bundle_item:
@@ -911,7 +910,6 @@ class PickList(TransactionBase):
item.sales_order_item,
"item_code",
),
"pick_list_item": item.name,
}
)
return product_bundles
@@ -937,6 +935,113 @@ class PickList(TransactionBase):
return int(flt(min(possible_bundles.values()), precision or 6)) if possible_bundles else 0
def update_bundle_delivered_qty(self):
bundle_locations = defaultdict(list)
for location in self.locations:
if location.product_bundle_item:
bundle_locations[
(
location.sales_order_item,
location.item_code,
location.warehouse,
location.batch_no or "",
location.serial_no or "",
)
].append(location)
if not bundle_locations:
return
delivered_component_qty = self._get_delivered_bundle_component_qty(
{bundle_key[0] for bundle_key in bundle_locations}
)
updates = {}
for bundle_key, locations in bundle_locations.items():
remaining_qty = max(delivered_component_qty.get(bundle_key, 0), 0)
for location in locations:
precision = location.precision("delivered_qty")
delivered_qty = flt(min(flt(location.picked_qty), remaining_qty), precision)
remaining_qty -= delivered_qty
if flt(location.delivered_qty, precision) == delivered_qty:
continue
location.delivered_qty = delivered_qty
updates[location.name] = {"delivered_qty": delivered_qty}
if updates:
frappe.db.bulk_update("Pick List Item", updates, update_modified=False)
self._set_delivery_status_from_items()
def _get_delivered_bundle_component_qty(self, sales_order_items):
delivered_qty = defaultdict(float)
for parenttype in ("Delivery Note", "Sales Invoice"):
for row in self._get_delivered_packed_items(parenttype, sales_order_items):
key = (
row.so_detail,
row.item_code,
row.warehouse,
row.batch_no or "",
row.serial_no or "",
)
delivered_qty[key] += flt(row.delivered_qty)
return delivered_qty
def _get_delivered_packed_items(self, parenttype, sales_order_items):
packed_item = frappe.qb.DocType("Packed Item")
transaction_item = frappe.qb.DocType(f"{parenttype} Item")
query = (
frappe.qb.from_(transaction_item)
.inner_join(packed_item)
.on(
(packed_item.parent == transaction_item.parent)
& (packed_item.parent_detail_docname == transaction_item.name)
& (packed_item.parenttype == parenttype)
)
)
conditions = (
(transaction_item.docstatus == 1)
& (transaction_item.against_pick_list == self.name)
& transaction_item.so_detail.isin(sales_order_items)
)
if parenttype == "Sales Invoice":
transaction = frappe.qb.DocType(parenttype)
query = query.inner_join(transaction).on(transaction.name == transaction_item.parent)
conditions &= transaction.update_stock == 1
return (
query.select(
transaction_item.so_detail,
packed_item.item_code,
packed_item.warehouse,
packed_item.batch_no,
packed_item.serial_no,
Sum(packed_item.qty).as_("delivered_qty"),
)
.where(conditions)
.groupby(
transaction_item.so_detail,
packed_item.item_code,
packed_item.warehouse,
packed_item.batch_no,
packed_item.serial_no,
)
).run(as_dict=True)
def _set_delivery_status_from_items(self):
per_delivered = self._calculate_target_parent_percentage(
self.name, "Pick List", "Pick List Item", "picked_qty", "delivered_qty"
)
delivery_status = self._determine_status(per_delivered, "Delivered")
self.per_delivered = per_delivered
self.delivery_status = delivery_status
self.db_set(
{"per_delivered": per_delivered, "delivery_status": delivery_status},
update_modified=False,
)
def has_unreserved_stock(self):
if self.purpose == "Delivery":
for location in self.locations:
@@ -965,6 +1070,7 @@ class PickList(TransactionBase):
def update_pick_list_status(pick_list):
if pick_list:
doc = frappe.get_doc("Pick List", pick_list)
doc.update_bundle_delivered_qty()
doc.run_method("update_status")
doc.update_picked_qty_in_work_order()

View File

@@ -1215,6 +1215,117 @@ class TestPickList(ERPNextTestSuite):
self.assertEqual(dn.packed_items[0].warehouse, warehouse)
so.reload()
self.assertEqual(so.per_delivered, 100)
pl.reload()
for location in pl.locations:
self.assertEqual(location.delivered_qty, location.picked_qty)
self.assertEqual(pl.per_delivered, 100)
self.assertEqual(pl.delivery_status, "Fully Delivered")
self.assertEqual(pl.status, "Completed")
dn.cancel()
pl.reload()
for location in pl.locations:
self.assertEqual(location.delivered_qty, 0)
self.assertEqual(pl.per_delivered, 0)
self.assertEqual(pl.delivery_status, "Not Delivered")
self.assertEqual(pl.status, "Open")
def test_picklist_with_bundle_from_sales_invoice(self):
warehouse = "_Test Warehouse - _TC"
bundle, _components = create_product_bundle([1, 1], warehouse=warehouse)
so = make_sales_order(item_code=bundle, qty=1, rate=42)
pl = create_pick_list(so.name).save().submit()
sales_invoice = create_delivery(pl.name, target="Sales Invoice").submit()
pl.reload()
for location in pl.locations:
self.assertEqual(location.delivered_qty, location.picked_qty)
self.assertEqual(pl.per_delivered, 100)
self.assertEqual(pl.delivery_status, "Fully Delivered")
self.assertEqual(pl.status, "Completed")
sales_invoice.cancel()
pl.reload()
for location in pl.locations:
self.assertEqual(location.delivered_qty, 0)
self.assertEqual(pl.per_delivered, 0)
self.assertEqual(pl.delivery_status, "Not Delivered")
self.assertEqual(pl.status, "Open")
def test_partial_delivery_of_picklist_with_bundle(self):
warehouse = "_Test Warehouse - _TC"
bundle, _components = create_product_bundle([1, 1], warehouse=warehouse)
so = make_sales_order(item_code=bundle, qty=2, rate=42)
pl = create_pick_list(so.name).save().submit()
dn = create_delivery_note(pl.name)
dn.items[0].qty = 1
dn.save().submit()
pl.reload()
for location in pl.locations:
self.assertEqual(location.delivered_qty, 1)
self.assertEqual(pl.per_delivered, 50)
self.assertEqual(pl.delivery_status, "Partly Delivered")
self.assertEqual(pl.status, "Partly Delivered")
dn.cancel()
pl.reload()
for location in pl.locations:
self.assertEqual(location.delivered_qty, 0)
self.assertEqual(pl.per_delivered, 0)
self.assertEqual(pl.delivery_status, "Not Delivered")
self.assertEqual(pl.status, "Open")
@ERPNextTestSuite.change_settings("Stock Settings", {"use_serial_batch_fields": 1})
def test_partial_bundle_delivery_with_split_pick_list_rows(self):
primary_warehouse = "_Test Warehouse - _TC"
secondary_warehouse = "_Test Warehouse 2 - _TC"
bundle = make_item(properties={"is_stock_item": 0}).name
serialized_component = make_item(
properties={
"has_serial_no": 1,
"serial_no_series": f"PL-BUNDLE-{frappe.generate_hash(length=6)}-.#####",
}
).name
other_component = make_item().name
make_product_bundle(bundle, [serialized_component, other_component])
make_stock_entry(item=serialized_component, to_warehouse=primary_warehouse, qty=1)
make_stock_entry(item=serialized_component, to_warehouse=secondary_warehouse, qty=2)
make_stock_entry(item=other_component, to_warehouse=primary_warehouse, qty=3)
so = make_sales_order(item_code=bundle, qty=3, rate=42)
pl = create_pick_list(so.name).save().submit()
serialized_locations = [row for row in pl.locations if row.item_code == serialized_component]
self.assertEqual(len(serialized_locations), 2)
dn = create_delivery_note(pl.name)
dn.items[0].qty = 2
dn.save().submit()
delivered_component = next(row for row in dn.packed_items if row.item_code == serialized_component)
self.assertEqual(delivered_component.warehouse, secondary_warehouse)
delivered_location = next(row for row in serialized_locations if row.warehouse == secondary_warehouse)
self.assertEqual(
set(delivered_component.serial_no.split("\n")),
set(delivered_location.serial_no.split("\n")),
)
pl.reload()
for location in pl.locations:
if location.item_code == serialized_component:
expected_qty = 2 if location.warehouse == secondary_warehouse else 0
self.assertEqual(location.delivered_qty, expected_qty)
else:
self.assertEqual(location.delivered_qty, 2)
self.assertEqual(pl.per_delivered, 66.666667)
self.assertEqual(pl.delivery_status, "Partly Delivered")
self.assertEqual(pl.status, "Partly Delivered")
dn.cancel()
pl.reload()
for location in pl.locations:
self.assertEqual(location.delivered_qty, 0)
def test_picklist_with_partial_bundles(self):
# from self.globalTestRecords