mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-19 11:27:55 +00:00
fix(stock): break the disassembly representative tie on a date, not a name (#59131)
This commit is contained in:
@@ -3414,6 +3414,68 @@ class TestWorkOrder(ERPNextTestSuite):
|
|||||||
self.assertEqual(flt(disassembly_row.transfer_qty), expected_disassembly_qty)
|
self.assertEqual(flt(disassembly_row.transfer_qty), expected_disassembly_qty)
|
||||||
disassembly.submit()
|
disassembly.submit()
|
||||||
|
|
||||||
|
def test_disassembly_representative_is_stable_when_entries_share_a_creation(self):
|
||||||
|
"""The representative line must be decided by the query, not by row order on disk.
|
||||||
|
|
||||||
|
When two Manufacture entries share a creation timestamp the entry name decides, as it always
|
||||||
|
did -- but compared in Python, so the database's collation does not.
|
||||||
|
"""
|
||||||
|
from erpnext.stock.doctype.stock_entry.services.disassemble import DisassembleStockEntry
|
||||||
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||||
|
make_stock_entry as make_stock_entry_test_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_item = make_item("Test RM for Disassembly Tie", {"is_stock_item": 1}).name
|
||||||
|
fg_item = make_item("Test FG for Disassembly Tie", {"is_stock_item": 1}).name
|
||||||
|
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
|
||||||
|
|
||||||
|
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
|
||||||
|
make_stock_entry_test_record(
|
||||||
|
item_code=raw_item,
|
||||||
|
purpose="Material Receipt",
|
||||||
|
target=wo.wip_warehouse,
|
||||||
|
qty=50,
|
||||||
|
basic_rate=100,
|
||||||
|
)
|
||||||
|
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
|
||||||
|
for item in transfer.items:
|
||||||
|
item.s_warehouse = wo.wip_warehouse
|
||||||
|
transfer.save()
|
||||||
|
transfer.submit()
|
||||||
|
|
||||||
|
first = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 5))
|
||||||
|
first.submit()
|
||||||
|
second = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 5))
|
||||||
|
second.submit()
|
||||||
|
|
||||||
|
shared_creation = frappe.db.get_value("Stock Entry", first.name, "creation")
|
||||||
|
frappe.db.set_value("Stock Entry", second.name, "creation", shared_creation, update_modified=False)
|
||||||
|
|
||||||
|
rows = frappe.get_all(
|
||||||
|
"Stock Entry Detail",
|
||||||
|
filters={"parent": ("in", [first.name, second.name]), "item_code": raw_item},
|
||||||
|
fields=["name", "parent", "idx"],
|
||||||
|
order_by="name",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(len({row.idx for row in rows}), 1, "idx must repeat for the tie to matter")
|
||||||
|
|
||||||
|
# mark the two lines apart on a column the representative alone supplies
|
||||||
|
warehouses = {}
|
||||||
|
for offset, row in enumerate(rows):
|
||||||
|
warehouse = create_warehouse(f"_Test Disassembly Tie {offset}")
|
||||||
|
warehouses[row.parent] = warehouse
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Stock Entry Detail", row.name, "s_warehouse", warehouse, update_modified=False
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DisassembleStockEntry(frappe._dict(work_order=wo.name, source_stock_entry=None))
|
||||||
|
source_row = next(
|
||||||
|
row for row in service.get_items_from_manufacture_stock_entry() if row.item_code == raw_item
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(source_row.s_warehouse, warehouses[min(warehouses, key=str.casefold)])
|
||||||
|
|
||||||
def test_disassembly_with_additional_rm_not_in_bom(self):
|
def test_disassembly_with_additional_rm_not_in_bom(self):
|
||||||
"""
|
"""
|
||||||
Test that SE-linked disassembly includes additional raw materials
|
Test that SE-linked disassembly includes additional raw materials
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ def _qty_tolerance(precision: int) -> float:
|
|||||||
return 1.0 / (10**precision)
|
return 1.0 / (10**precision)
|
||||||
|
|
||||||
|
|
||||||
|
def _manufacture_line_order(line):
|
||||||
|
return (line.parent_creation, line.parent_name.casefold(), line.parent_name, line.idx)
|
||||||
|
|
||||||
|
|
||||||
class DisassembleStockEntry(BaseStockEntry):
|
class DisassembleStockEntry(BaseStockEntry):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_warehouse()
|
self.validate_warehouse()
|
||||||
@@ -382,6 +386,10 @@ class DisassembleStockEntry(BaseStockEntry):
|
|||||||
is_finished_item decides whether the row is the output or an input. Aggregating each column
|
is_finished_item decides whether the row is the output or an input. Aggregating each column
|
||||||
on its own can pair values from different lines into a row that was never posted, so take
|
on its own can pair values from different lines into a row that was never posted, so take
|
||||||
the columns from a single real line instead. UOM is normalized separately to stock UOM.
|
the columns from a single real line instead. UOM is normalized separately to stock UOM.
|
||||||
|
|
||||||
|
Ordered in Python rather than SQL, keeping the original (entry creation, entry name, line idx)
|
||||||
|
precedence. casefold reproduces MariaDB's case-insensitive collation without depending on the
|
||||||
|
database's, which PostgreSQL resolves by byte value.
|
||||||
"""
|
"""
|
||||||
SE = frappe.qb.DocType("Stock Entry")
|
SE = frappe.qb.DocType("Stock Entry")
|
||||||
SED = frappe.qb.DocType("Stock Entry Detail")
|
SED = frappe.qb.DocType("Stock Entry Detail")
|
||||||
@@ -391,6 +399,9 @@ class DisassembleStockEntry(BaseStockEntry):
|
|||||||
.join(SE)
|
.join(SE)
|
||||||
.on(SED.parent == SE.name)
|
.on(SED.parent == SE.name)
|
||||||
.select(
|
.select(
|
||||||
|
SE.creation.as_("parent_creation"),
|
||||||
|
SE.name.as_("parent_name"),
|
||||||
|
SED.idx,
|
||||||
SED.item_code,
|
SED.item_code,
|
||||||
SED.item_name,
|
SED.item_name,
|
||||||
SED.description,
|
SED.description,
|
||||||
@@ -409,14 +420,14 @@ class DisassembleStockEntry(BaseStockEntry):
|
|||||||
.where(
|
.where(
|
||||||
(SE.docstatus == 1) & (SE.purpose == "Manufacture") & (SE.work_order == self.doc.work_order)
|
(SE.docstatus == 1) & (SE.purpose == "Manufacture") & (SE.work_order == self.doc.work_order)
|
||||||
)
|
)
|
||||||
.orderby(SE.creation)
|
|
||||||
.orderby(SE.name)
|
|
||||||
.orderby(SED.idx)
|
|
||||||
.run(as_dict=True)
|
.run(as_dict=True)
|
||||||
)
|
)
|
||||||
|
lines.sort(key=_manufacture_line_order)
|
||||||
|
|
||||||
representative = {}
|
representative = {}
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
line.pop("parent_creation")
|
||||||
|
line.pop("parent_name")
|
||||||
representative.setdefault(line.item_code, line)
|
representative.setdefault(line.item_code, line)
|
||||||
|
|
||||||
return representative
|
return representative
|
||||||
|
|||||||
Reference in New Issue
Block a user