mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-26 05:15:20 +00:00
fix(stock): update pick list status for product bundles (#58306)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user