mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 06:31:48 +00:00
fix(job_card): require the previous operation to be manufactured (#57684)
* fix(job_card): block next operation until previous operation is manufactured
With track semi finished goods, Work Order Operation completed_qty is set from
the submitted job cards' total completed qty, so a job card of the next
operation could be started and completed even when no Manufacture entry existed
for the previous operation. The semi-finished goods it consumes were never
produced.
Validate the sequence against the qty actually manufactured against the previous
operations' job cards (Manufacture entries / Subcontracting Receipts) when the
work order tracks semi finished goods.
* test(job_card): cover manufactured qty check across previous operations
Work order with operations A and B at sequence 1 and C at sequence 2, tracking
semi finished goods. C stays blocked while A's job card is submitted but its
Manufacture entry is missing, and once A is manufactured for 3, C can only be
completed for 3.
(cherry picked from commit 3bd3354152)
# Conflicts:
# erpnext/manufacturing/doctype/job_card/job_card.py
# erpnext/manufacturing/doctype/job_card/test_job_card.py
This commit is contained in:
@@ -1344,18 +1344,58 @@ class JobCard(Document):
|
|||||||
if data and len(data) > 0:
|
if data and len(data) > 0:
|
||||||
current_operation_qty = flt(data[0].completed_qty)
|
current_operation_qty = flt(data[0].completed_qty)
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
current_operation_qty += flt(self.total_completed_qty)
|
current_operation_qty += flt(self.total_completed_qty)
|
||||||
|
|
||||||
data = frappe.get_all(
|
data = frappe.get_all(
|
||||||
|
=======
|
||||||
|
for row in self.get_previous_operations():
|
||||||
|
if self.track_semi_finished_goods:
|
||||||
|
self.validate_previous_operation_manufactured_qty(row, current_operation_qty)
|
||||||
|
else:
|
||||||
|
self.validate_previous_operation(row, current_operation_qty)
|
||||||
|
|
||||||
|
def get_previous_operations(self):
|
||||||
|
previous_operations = frappe.get_all(
|
||||||
|
>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684))
|
||||||
"Work Order Operation",
|
"Work Order Operation",
|
||||||
fields=["operation", "status", "completed_qty", "sequence_id"],
|
fields=["name", "operation", "status", "completed_qty", "sequence_id"],
|
||||||
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
|
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
|
||||||
order_by="sequence_id, idx",
|
order_by="sequence_id, idx",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
message = "Job Card {}: As per the sequence of the operations in the work order {}".format(
|
message = "Job Card {}: As per the sequence of the operations in the work order {}".format(
|
||||||
bold(self.name), bold(get_link_to_form("Work Order", self.work_order))
|
bold(self.name), bold(get_link_to_form("Work Order", self.work_order))
|
||||||
)
|
)
|
||||||
|
=======
|
||||||
|
if self.track_semi_finished_goods and previous_operations:
|
||||||
|
manufactured_qty = self.get_manufactured_qty_per_operation(
|
||||||
|
[row.name for row in previous_operations]
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in previous_operations:
|
||||||
|
row.manufactured_qty = flt(manufactured_qty.get(row.name))
|
||||||
|
|
||||||
|
return previous_operations
|
||||||
|
|
||||||
|
def get_manufactured_qty_per_operation(self, operation_ids):
|
||||||
|
job_card = frappe.qb.DocType("Job Card")
|
||||||
|
|
||||||
|
data = (
|
||||||
|
frappe.qb.from_(job_card)
|
||||||
|
.select(job_card.operation_id, Sum(job_card.manufactured_qty))
|
||||||
|
.where(
|
||||||
|
(job_card.work_order == self.work_order)
|
||||||
|
& (job_card.docstatus == 1)
|
||||||
|
& (IfNull(job_card.is_corrective_job_card, 0) == 0)
|
||||||
|
& (job_card.operation_id.isin(operation_ids))
|
||||||
|
)
|
||||||
|
.groupby(job_card.operation_id)
|
||||||
|
).run()
|
||||||
|
|
||||||
|
return dict(data)
|
||||||
|
>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684))
|
||||||
|
|
||||||
for row in data:
|
for row in data:
|
||||||
if not row.completed_qty:
|
if not row.completed_qty:
|
||||||
@@ -1386,6 +1426,35 @@ class JobCard(Document):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
|
||||||
|
manufactured_qty = flt(row.manufactured_qty)
|
||||||
|
|
||||||
|
if not manufactured_qty:
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"Job Card {0}: As per the sequence of the operations in the work order {1}, submit the manufacturing entry for the operation {2} before the operation {3}."
|
||||||
|
).format(
|
||||||
|
bold(self.name),
|
||||||
|
bold(get_link_to_form("Work Order", self.work_order)),
|
||||||
|
bold(row.operation),
|
||||||
|
bold(self.operation),
|
||||||
|
),
|
||||||
|
OperationSequenceError,
|
||||||
|
)
|
||||||
|
|
||||||
|
if manufactured_qty < current_operation_qty:
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
|
||||||
|
).format(
|
||||||
|
bold(current_operation_qty),
|
||||||
|
bold(self.operation),
|
||||||
|
bold(manufactured_qty),
|
||||||
|
bold(row.operation),
|
||||||
|
),
|
||||||
|
OperationSequenceError,
|
||||||
|
)
|
||||||
|
|
||||||
def validate_work_order(self):
|
def validate_work_order(self):
|
||||||
if self.is_work_order_closed():
|
if self.is_work_order_closed():
|
||||||
frappe.throw(_("You can't make any changes to Job Card since Work Order is closed."))
|
frappe.throw(_("You can't make any changes to Job Card since Work Order is closed."))
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ from frappe.utils.data import add_to_date, now, today
|
|||||||
|
|
||||||
from erpnext.manufacturing.doctype.job_card.job_card import (
|
from erpnext.manufacturing.doctype.job_card.job_card import (
|
||||||
JobCardOverTransferError,
|
JobCardOverTransferError,
|
||||||
|
<<<<<<< HEAD
|
||||||
OperationMismatchError,
|
OperationMismatchError,
|
||||||
|
=======
|
||||||
|
OperationSequenceError,
|
||||||
|
>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684))
|
||||||
OverlapError,
|
OverlapError,
|
||||||
make_corrective_job_card,
|
make_corrective_job_card,
|
||||||
make_material_request,
|
make_material_request,
|
||||||
@@ -1265,6 +1269,144 @@ class TestJobCard(ERPNextTestSuite):
|
|||||||
8,
|
8,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_semi_fg_sequence_needs_previous_operations_manufactured(self):
|
||||||
|
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||||
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
|
|
||||||
|
warehouse = "Stores - _TC"
|
||||||
|
rm1 = make_item("Sequence Check RM 1", {"is_stock_item": 1}).name
|
||||||
|
rm2 = make_item("Sequence Check RM 2", {"is_stock_item": 1}).name
|
||||||
|
sfg1 = make_item("Sequence Check SFG 1", {"is_stock_item": 1}).name
|
||||||
|
sfg2 = make_item("Sequence Check SFG 2", {"is_stock_item": 1}).name
|
||||||
|
fg = make_item("Sequence Check FG 1", {"is_stock_item": 1}).name
|
||||||
|
|
||||||
|
semi_fg_boms = {}
|
||||||
|
for semi_fg_item, raw_material in ((sfg1, rm1), (sfg2, rm2)):
|
||||||
|
bom = frappe.new_doc("BOM", company="_Test Company", item=semi_fg_item, quantity=1)
|
||||||
|
bom.append("items", {"item_code": raw_material, "qty": 1})
|
||||||
|
bom.insert()
|
||||||
|
bom.submit()
|
||||||
|
semi_fg_boms[semi_fg_item] = bom.name
|
||||||
|
|
||||||
|
fg_bom = frappe.new_doc(
|
||||||
|
"BOM",
|
||||||
|
company="_Test Company",
|
||||||
|
item=fg,
|
||||||
|
quantity=1,
|
||||||
|
with_operations=1,
|
||||||
|
track_semi_finished_goods=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
{
|
||||||
|
"operation": "Sequence Check Op A",
|
||||||
|
"finished_good": sfg1,
|
||||||
|
"bom_no": semi_fg_boms[sfg1],
|
||||||
|
"sequence_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "Sequence Check Op B",
|
||||||
|
"finished_good": sfg2,
|
||||||
|
"bom_no": semi_fg_boms[sfg2],
|
||||||
|
"sequence_id": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "Sequence Check Op C",
|
||||||
|
"finished_good": fg,
|
||||||
|
"is_final_finished_good": 1,
|
||||||
|
"sequence_id": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for row in operations:
|
||||||
|
row.update(
|
||||||
|
{
|
||||||
|
"workstation": "_Test Workstation A",
|
||||||
|
"finished_good_qty": 1,
|
||||||
|
"time_in_mins": 60,
|
||||||
|
"source_warehouse": warehouse,
|
||||||
|
"fg_warehouse": warehouse,
|
||||||
|
"skip_material_transfer": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
make_workstation(row)
|
||||||
|
make_operation(row)
|
||||||
|
fg_bom.append("operations", row)
|
||||||
|
|
||||||
|
fg_bom.append("items", {"item_code": sfg1, "qty": 1, "operation_row_id": 3})
|
||||||
|
fg_bom.append("items", {"item_code": sfg2, "qty": 1, "operation_row_id": 3})
|
||||||
|
fg_bom.insert()
|
||||||
|
fg_bom.submit()
|
||||||
|
|
||||||
|
work_order = make_wo_order_test_record(
|
||||||
|
item=fg,
|
||||||
|
qty=5,
|
||||||
|
source_warehouse=warehouse,
|
||||||
|
fg_warehouse=warehouse,
|
||||||
|
bom_no=fg_bom.name,
|
||||||
|
skip_transfer=1,
|
||||||
|
do_not_save=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in work_order.operations:
|
||||||
|
row.time_in_mins = 60
|
||||||
|
|
||||||
|
work_order.save()
|
||||||
|
work_order.submit()
|
||||||
|
|
||||||
|
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
|
||||||
|
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
|
||||||
|
|
||||||
|
def get_job_card(operation):
|
||||||
|
return frappe.get_doc(
|
||||||
|
"Job Card",
|
||||||
|
frappe.db.get_value(
|
||||||
|
"Job Card",
|
||||||
|
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
|
||||||
|
"name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_time_log(job_card, day, qty):
|
||||||
|
job_card.append(
|
||||||
|
"time_logs",
|
||||||
|
{
|
||||||
|
"from_time": f"2024-01-{day} 08:00:00",
|
||||||
|
"to_time": f"2024-01-{day} 09:00:00",
|
||||||
|
"completed_qty": qty,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
jc_a = get_job_card("Sequence Check Op A")
|
||||||
|
jc_a.for_quantity = 3
|
||||||
|
add_time_log(jc_a, "01", 3)
|
||||||
|
jc_a.submit()
|
||||||
|
|
||||||
|
jc_b = get_job_card("Sequence Check Op B")
|
||||||
|
add_time_log(jc_b, "02", jc_b.for_quantity)
|
||||||
|
jc_b.submit()
|
||||||
|
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
|
||||||
|
|
||||||
|
jc_c = get_job_card("Sequence Check Op C")
|
||||||
|
jc_c.for_quantity = 3
|
||||||
|
add_time_log(jc_c, "03", 3)
|
||||||
|
self.assertRaises(OperationSequenceError, jc_c.save)
|
||||||
|
|
||||||
|
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||||
|
|
||||||
|
jc_c.reload()
|
||||||
|
jc_c.for_quantity = 4
|
||||||
|
add_time_log(jc_c, "03", 4)
|
||||||
|
self.assertRaises(OperationSequenceError, jc_c.save)
|
||||||
|
|
||||||
|
jc_c.reload()
|
||||||
|
jc_c.for_quantity = 3
|
||||||
|
add_time_log(jc_c, "03", 3)
|
||||||
|
jc_c.submit()
|
||||||
|
|
||||||
|
self.assertEqual(jc_c.docstatus, 1)
|
||||||
|
|
||||||
def test_semi_fg_batch_auto_pull_on_manufacture(self):
|
def test_semi_fg_batch_auto_pull_on_manufacture(self):
|
||||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||||
from erpnext.stock.doctype.item.test_item import make_item
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
|
|||||||
Reference in New Issue
Block a user