mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 07:58:38 +00:00
chore: resolve conflict
This commit is contained in:
@@ -282,6 +282,7 @@ class BOM(WebsiteGenerator):
|
||||
self.clear_inspection()
|
||||
self.validate_main_item()
|
||||
self.validate_currency()
|
||||
self.set_operation_finished_goods()
|
||||
self.set_materials_based_on_operation_bom()
|
||||
self.set_conversion_rate()
|
||||
self.set_plc_conversion_rate()
|
||||
@@ -307,15 +308,42 @@ class BOM(WebsiteGenerator):
|
||||
if self.docstatus == 1:
|
||||
self.validate_raw_materials_of_operation()
|
||||
|
||||
def set_operation_finished_goods(self):
|
||||
"""Fill each operation's FG item where it is unambiguous: the final operation produces
|
||||
this BOM's item, an operation with a BOM produces that BOM's item. Runs before
|
||||
set_materials_based_on_operation_bom so derived rows get their materials expanded."""
|
||||
if not self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
for row in self.operations:
|
||||
if row.is_final_finished_good and not row.finished_good:
|
||||
row.finished_good = self.item
|
||||
elif row.bom_no and not row.finished_good:
|
||||
row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item")
|
||||
|
||||
def validate_semi_finished_goods(self):
|
||||
if not self.track_semi_finished_goods or not self.operations:
|
||||
return
|
||||
|
||||
fg_items = []
|
||||
for row in self.operations:
|
||||
if not row.finished_good:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: FG / Semi FG Item is required for the operation {1} as 'Track Semi Finished Goods' is enabled."
|
||||
).format(row.idx, bold(row.operation)),
|
||||
)
|
||||
|
||||
if not row.is_final_finished_good:
|
||||
continue
|
||||
|
||||
if row.finished_good != self.item:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: The operation {1} has 'Is Final Finished Good' checked, so its FG / Semi FG Item must be {2}."
|
||||
).format(row.idx, bold(row.operation), bold(self.item)),
|
||||
)
|
||||
|
||||
fg_items.append(row.finished_good)
|
||||
|
||||
if not fg_items:
|
||||
@@ -826,7 +854,7 @@ class BOM(WebsiteGenerator):
|
||||
self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty)
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_raw_materials(self, operation_row_id, items):
|
||||
def add_raw_materials(self, operation_row_id: str | int, items: str | list[dict]) -> None:
|
||||
if isinstance(items, str):
|
||||
items = parse_json(items)
|
||||
|
||||
@@ -836,17 +864,10 @@ class BOM(WebsiteGenerator):
|
||||
row.update(get_item_details(row.get("item_code")))
|
||||
row.operation_row_id = operation_row_id
|
||||
|
||||
item_row = None
|
||||
if row.name:
|
||||
item_row = self.get_item_data(row.name)
|
||||
item_row = self.get_item_data(row.item_code, operation_row_id)
|
||||
|
||||
if item_row:
|
||||
item_row.update(
|
||||
{
|
||||
"item_code": row.get("item_code"),
|
||||
"qty": row.get("qty"),
|
||||
}
|
||||
)
|
||||
item_row.qty = row.get("qty")
|
||||
else:
|
||||
row.idx = None
|
||||
row.name = None
|
||||
@@ -867,9 +888,9 @@ class BOM(WebsiteGenerator):
|
||||
|
||||
return False
|
||||
|
||||
def get_item_data(self, name):
|
||||
def get_item_data(self, item_code, operation_row_id):
|
||||
for row in self.items:
|
||||
if row.item_code == name:
|
||||
if row.item_code == item_code and cint(row.operation_row_id) == cint(operation_row_id):
|
||||
return row
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -7,7 +7,7 @@ from functools import partial
|
||||
|
||||
import frappe
|
||||
from frappe.tests import timeout
|
||||
from frappe.utils import cstr, flt
|
||||
from frappe.utils import cint, cstr, flt
|
||||
|
||||
from erpnext.controllers.tests.test_subcontracting_controller import (
|
||||
set_backflush_based_on,
|
||||
@@ -811,6 +811,207 @@ class TestBOM(ERPNextTestSuite):
|
||||
for row in bom.items:
|
||||
self.assertEqual(row.stock_uom, "Kg")
|
||||
|
||||
@timeout
|
||||
def test_track_semi_finished_goods_requires_finished_good_on_operations(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
|
||||
# the first operation produces nothing derivable: no FG item, no BOM to take it from
|
||||
self.assertRaises(frappe.ValidationError, bom.insert)
|
||||
|
||||
bom.operations[0].finished_good = sfg_item
|
||||
bom.insert()
|
||||
|
||||
# the final operation's FG item is derived from the BOM's own item
|
||||
self.assertEqual(bom.operations[1].finished_good, fg_item)
|
||||
|
||||
@timeout
|
||||
def test_add_raw_materials_when_item_is_used_by_another_operation(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
bom.insert()
|
||||
|
||||
def rows_for(item_code, operation_row_id):
|
||||
return [
|
||||
row
|
||||
for row in bom.items
|
||||
if row.item_code == item_code and cint(row.operation_row_id) == operation_row_id
|
||||
]
|
||||
|
||||
# the item already used by operation 1 gets its own new row under operation 2
|
||||
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 3}])
|
||||
self.assertEqual(len(rows_for(rm_item, 2)), 1)
|
||||
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 3.0)
|
||||
self.assertEqual(flt(rows_for(rm_item, 1)[0].qty), 1.0)
|
||||
|
||||
# adding it again for the same operation updates the row instead of stacking another
|
||||
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 5}])
|
||||
self.assertEqual(len(rows_for(rm_item, 2)), 1)
|
||||
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0)
|
||||
|
||||
@timeout
|
||||
def test_operation_bom_materials_expand_on_single_pass_submit(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg_item, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm_item, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"bom_no": sfg_bom.name,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
bom.submit()
|
||||
|
||||
self.assertEqual(bom.docstatus, 1)
|
||||
self.assertEqual(bom.operations[0].finished_good, sfg_item)
|
||||
self.assertTrue(
|
||||
any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items)
|
||||
)
|
||||
|
||||
@timeout
|
||||
def test_final_operation_must_produce_the_bom_item(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
|
||||
# the final operation claims to produce the semi FG, not this BOM's item
|
||||
self.assertRaises(frappe.ValidationError, bom.insert)
|
||||
|
||||
bom.operations[1].finished_good = fg_item
|
||||
bom.insert()
|
||||
|
||||
|
||||
def get_default_bom(item_code="_Test FG Item 2"):
|
||||
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "FG / Semi FG Item",
|
||||
"mandatory_depends_on": "eval:parent.track_semi_finished_goods === 1",
|
||||
"options": "Item"
|
||||
},
|
||||
{
|
||||
@@ -307,7 +308,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-25 17:15:42.044630",
|
||||
"modified": "2026-08-08 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Operation",
|
||||
|
||||
@@ -103,7 +103,8 @@ frappe.ui.form.on("Job Card", {
|
||||
doc.docstatus === 1 &&
|
||||
!doc.is_subcontracted &&
|
||||
(doc.skip_material_transfer || doc.transferred_qty > 0) &&
|
||||
flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < flt(doc.for_quantity);
|
||||
flt(doc.manufactured_qty) + flt(doc.process_loss_qty) <
|
||||
flt(doc.for_quantity) - flt(doc.pending_qty);
|
||||
|
||||
if (!can_make_stock_entry) return;
|
||||
|
||||
@@ -247,13 +248,15 @@ frappe.ui.form.on("Job Card", {
|
||||
const fields = [
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Qty to Manufacture"),
|
||||
label: __("Qty to Manufacture in this Cycle"),
|
||||
fieldname: "for_quantity",
|
||||
reqd: 1,
|
||||
default: pending_qty,
|
||||
description: __("Completed, Pending and Process Loss quantities must add up to this."),
|
||||
change() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
dialog.set_value("completed_qty", dialog.get_value("for_quantity"));
|
||||
dialog.set_value("pending_qty", 0);
|
||||
dialog.set_value("process_loss_qty", 0);
|
||||
},
|
||||
},
|
||||
@@ -265,10 +268,6 @@ frappe.ui.form.on("Job Card", {
|
||||
default: pending_qty,
|
||||
change() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
<<<<<<< HEAD
|
||||
const remaining = dialog.get_value("for_quantity") - dialog.get_value("completed_qty");
|
||||
if (remaining > 0 && remaining != dialog.get_value("pending_qty")) {
|
||||
=======
|
||||
const remaining =
|
||||
dialog.get_value("for_quantity") -
|
||||
dialog.get_value("completed_qty") -
|
||||
@@ -286,7 +285,6 @@ frappe.ui.form.on("Job Card", {
|
||||
}
|
||||
|
||||
if (remaining != dialog.get_value("pending_qty")) {
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
dialog.set_value("pending_qty", remaining);
|
||||
}
|
||||
},
|
||||
@@ -296,15 +294,13 @@ frappe.ui.form.on("Job Card", {
|
||||
label: __("Pending Quantity"),
|
||||
fieldname: "pending_qty",
|
||||
default: 0.0,
|
||||
description: __("Qty left for a later cycle or for another job card."),
|
||||
change() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
const process_loss_qty =
|
||||
dialog.get_value("for_quantity") -
|
||||
dialog.get_value("completed_qty") -
|
||||
dialog.get_value("pending_qty");
|
||||
<<<<<<< HEAD
|
||||
if (process_loss_qty >= 0 && process_loss_qty != dialog.get_value("process_loss_qty")) {
|
||||
=======
|
||||
|
||||
if (process_loss_qty < 0) {
|
||||
dialog.set_value("pending_qty", 0);
|
||||
@@ -320,7 +316,6 @@ frappe.ui.form.on("Job Card", {
|
||||
}
|
||||
|
||||
if (process_loss_qty != dialog.get_value("process_loss_qty")) {
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
dialog.set_value("process_loss_qty", process_loss_qty);
|
||||
}
|
||||
},
|
||||
@@ -329,15 +324,13 @@ frappe.ui.form.on("Job Card", {
|
||||
fieldtype: "Float",
|
||||
label: __("Process Loss Quantity"),
|
||||
fieldname: "process_loss_qty",
|
||||
description: __("Qty scrapped in this cycle, nobody will produce it."),
|
||||
onchange() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
const remaining =
|
||||
dialog.get_value("for_quantity") -
|
||||
dialog.get_value("completed_qty") -
|
||||
dialog.get_value("process_loss_qty");
|
||||
<<<<<<< HEAD
|
||||
if (remaining >= 0 && remaining != dialog.get_value("pending_qty")) {
|
||||
=======
|
||||
|
||||
if (remaining < 0) {
|
||||
dialog.set_value("process_loss_qty", 0);
|
||||
@@ -353,7 +346,6 @@ frappe.ui.form.on("Job Card", {
|
||||
}
|
||||
|
||||
if (remaining != dialog.get_value("pending_qty")) {
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
dialog.set_value("pending_qty", remaining);
|
||||
}
|
||||
},
|
||||
@@ -417,9 +409,8 @@ frappe.ui.form.on("Job Card", {
|
||||
},
|
||||
});
|
||||
},
|
||||
__("Enter Value"),
|
||||
__("Update"),
|
||||
__("Set Finished Good Quantity")
|
||||
__("Complete Job"),
|
||||
__("Update")
|
||||
);
|
||||
},
|
||||
|
||||
@@ -445,46 +436,6 @@ frappe.ui.form.on("Job Card", {
|
||||
});
|
||||
},
|
||||
|
||||
make_finished_good(frm) {
|
||||
const fields = [
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Completed Quantity"),
|
||||
fieldname: "qty",
|
||||
reqd: 1,
|
||||
default: frm.doc.for_quantity - frm.doc.manufactured_qty,
|
||||
},
|
||||
{
|
||||
fieldtype: "Datetime",
|
||||
label: __("End Time"),
|
||||
fieldname: "end_time",
|
||||
default: frappe.datetime.now_datetime(),
|
||||
},
|
||||
];
|
||||
|
||||
frappe.prompt(
|
||||
fields,
|
||||
(data) => {
|
||||
if (data.qty <= 0) {
|
||||
frappe.throw(__("Quantity should be greater than 0"));
|
||||
}
|
||||
|
||||
frm.call({
|
||||
method: "make_finished_good",
|
||||
doc: frm.doc,
|
||||
args: { qty: data.qty, end_time: data.end_time },
|
||||
callback(r) {
|
||||
const doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
},
|
||||
__("Enter Value"),
|
||||
__("Update"),
|
||||
__("Set Finished Good Quantity")
|
||||
);
|
||||
},
|
||||
|
||||
setup_quality_inspection(frm) {
|
||||
const quality_inspection_field = frm.get_docfield("quality_inspection");
|
||||
quality_inspection_field.get_route_options_for_new_doc = function (frm) {
|
||||
|
||||
@@ -703,11 +703,7 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
<<<<<<< HEAD
|
||||
"modified": "2026-06-19 17:39:42.293242",
|
||||
=======
|
||||
"modified": "2026-08-01 14:22:19.926911",
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card",
|
||||
|
||||
@@ -847,6 +847,9 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
def validate_transfer_qty(self):
|
||||
if self.track_semi_finished_goods and self.skip_material_transfer:
|
||||
return
|
||||
|
||||
if (
|
||||
not self.finished_good
|
||||
and not self.is_corrective_job_card
|
||||
@@ -897,24 +900,14 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
precision = self.precision("total_completed_qty")
|
||||
total_completed_qty = flt(
|
||||
accounted_qty = flt(
|
||||
flt(self.total_completed_qty, precision)
|
||||
+ flt(self.process_loss_qty, precision)
|
||||
+ flt(self.pending_qty, precision)
|
||||
)
|
||||
|
||||
if self.for_quantity and flt(total_completed_qty, precision) != flt(self.for_quantity, precision):
|
||||
total_completed_qty_label = bold(_("Total Completed Qty"))
|
||||
qty_to_manufacture = bold(_("Qty to Manufacture"))
|
||||
|
||||
if self.for_quantity and flt(accounted_qty, precision) != flt(self.for_quantity, precision):
|
||||
frappe.throw(
|
||||
<<<<<<< HEAD
|
||||
_("The {0} ({1}) must be equal to {2} ({3})").format(
|
||||
total_completed_qty_label,
|
||||
bold(flt(total_completed_qty, precision)),
|
||||
qty_to_manufacture,
|
||||
bold(self.for_quantity),
|
||||
=======
|
||||
_(
|
||||
"Total Completed Qty ({0}), Process Loss Qty ({1}) and Pending Qty ({2}) must add up to the Qty to Manufacture ({3})."
|
||||
).format(
|
||||
@@ -922,7 +915,6 @@ class JobCard(Document):
|
||||
bold(self.get_qty_with_uom(self.process_loss_qty)),
|
||||
bold(self.get_qty_with_uom(self.pending_qty)),
|
||||
bold(self.get_qty_with_uom(self.for_quantity)),
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1085,6 +1077,9 @@ class JobCard(Document):
|
||||
wo.calculate_operating_cost()
|
||||
wo.set_actual_dates()
|
||||
|
||||
if wo.track_semi_finished_goods:
|
||||
wo.set_process_loss_qty()
|
||||
|
||||
if time_data:
|
||||
wo.status = "In Process"
|
||||
|
||||
@@ -1174,7 +1169,10 @@ class JobCard(Document):
|
||||
_(
|
||||
"Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
|
||||
).format(
|
||||
row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card
|
||||
row.idx,
|
||||
frappe.bold(self.get_qty_with_uom(required_qty, row.item_code)),
|
||||
frappe.bold(row.item_code),
|
||||
ste_doc.job_card,
|
||||
),
|
||||
title=_("Excess Transfer"),
|
||||
exc=JobCardOverTransferError,
|
||||
@@ -1196,51 +1194,6 @@ class JobCard(Document):
|
||||
|
||||
self.set_status(update_status=True)
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
def get_job_card_items_transferred_qty(self, ste_doc):
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
job_card_items = [x.get("job_card_item") for x in ste_doc.get("items") if x.get("job_card_item")]
|
||||
if not job_card_items:
|
||||
return {}
|
||||
|
||||
se = frappe.qb.DocType("Stock Entry")
|
||||
sed = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sed)
|
||||
.join(se)
|
||||
.on(sed.parent == se.name)
|
||||
.select(sed.job_card_item, Sum(sed.qty))
|
||||
.where(
|
||||
(sed.job_card_item.isin(job_card_items))
|
||||
& (se.docstatus == 1)
|
||||
& (se.purpose == "Material Transfer for Manufacture")
|
||||
)
|
||||
.groupby(sed.job_card_item)
|
||||
)
|
||||
|
||||
return frappe._dict(query.run(as_list=True))
|
||||
|
||||
def validate_over_transfer(self, ste_doc, row, transferred_qty):
|
||||
"Block over transfer of items if not allowed in settings."
|
||||
required_qty = frappe.db.get_value("Job Card Item", row.job_card_item, "required_qty")
|
||||
if flt(transferred_qty) > flt(required_qty):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
|
||||
).format(
|
||||
row.idx,
|
||||
frappe.bold(self.get_qty_with_uom(required_qty, row.item_code)),
|
||||
frappe.bold(row.item_code),
|
||||
ste_doc.job_card,
|
||||
),
|
||||
title=_("Excess Transfer"),
|
||||
exc=JobCardOverTransferError,
|
||||
)
|
||||
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
def set_transferred_qty(self, update_status=False):
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
@@ -1293,7 +1246,7 @@ class JobCard(Document):
|
||||
def set_status(self, update_status=False):
|
||||
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
|
||||
if self.finished_good and self.docstatus == 1:
|
||||
if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
|
||||
if (self.manufactured_qty + self.process_loss_qty) >= self.get_qty_to_produce():
|
||||
self.status = "Completed"
|
||||
elif self.transferred_qty > 0 or self.skip_material_transfer:
|
||||
self.status = "Work In Progress"
|
||||
@@ -1324,7 +1277,8 @@ class JobCard(Document):
|
||||
self.status = "Work In Progress"
|
||||
|
||||
if self.docstatus == 1 and (
|
||||
self.for_quantity <= (self.total_completed_qty + self.process_loss_qty) or not self.items
|
||||
self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty)
|
||||
or not self.items
|
||||
):
|
||||
self.status = "Completed"
|
||||
|
||||
@@ -1337,8 +1291,6 @@ class JobCard(Document):
|
||||
if self.workstation:
|
||||
self.update_workstation_status()
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
def get_qty_to_produce(self):
|
||||
"""Qty this job card is expected to produce, the pending qty is left to another job card."""
|
||||
return flt(self.for_quantity) - flt(self.pending_qty)
|
||||
@@ -1351,52 +1303,15 @@ class JobCard(Document):
|
||||
|
||||
return f"{flt(qty, self.precision('total_completed_qty'))} {uom or ''}".strip()
|
||||
|
||||
def set_finished_good_status(self):
|
||||
# Only reached for a submitted job card (docstatus == 1) with a finished good, see set_status().
|
||||
qty_to_produce = self.get_qty_to_produce()
|
||||
|
||||
if (self.manufactured_qty + self.process_loss_qty) >= qty_to_produce:
|
||||
self.status = "Completed"
|
||||
elif (self.total_completed_qty + self.process_loss_qty) >= qty_to_produce:
|
||||
# Production is done and the card is submitted, but the finished goods have not been
|
||||
# booked into stock yet (Manufacture Stock Entry pending) — distinct from active WIP.
|
||||
self.status = "To Manufacture"
|
||||
elif self.transferred_qty > 0 or self.skip_material_transfer:
|
||||
self.status = "Work In Progress"
|
||||
|
||||
def set_non_semi_fg_status(self):
|
||||
if self.items:
|
||||
item_data = frappe.get_all(
|
||||
"Job Card Item",
|
||||
filters={"parent": self.name},
|
||||
fields=["transferred_qty", "required_qty"],
|
||||
)
|
||||
all_transferred = item_data and all(
|
||||
flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data
|
||||
)
|
||||
any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data)
|
||||
|
||||
if all_transferred:
|
||||
self.status = "Material Transferred"
|
||||
elif any_transferred:
|
||||
self.status = "Partially Transferred"
|
||||
elif flt(self.for_quantity) <= flt(self.transferred_qty):
|
||||
self.status = "Material Transferred"
|
||||
|
||||
if self.time_logs:
|
||||
self.status = "Work In Progress"
|
||||
|
||||
if self.docstatus == 1 and (
|
||||
self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty) or not self.items
|
||||
):
|
||||
self.status = "Completed"
|
||||
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
def set_wip_warehouse(self):
|
||||
if not self.wip_warehouse:
|
||||
self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse")
|
||||
|
||||
<<<<<<< HEAD
|
||||
def set_stock_uom(self):
|
||||
item_code = self.finished_good or self.production_item
|
||||
if item_code:
|
||||
self.stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
|
||||
|
||||
def validate_operation_id(self):
|
||||
if (
|
||||
self.get("operation_id")
|
||||
@@ -1407,36 +1322,6 @@ class JobCard(Document):
|
||||
!= self.operation_id
|
||||
):
|
||||
work_order = bold(get_link_to_form("Work Order", self.work_order))
|
||||
=======
|
||||
def set_stock_uom(self):
|
||||
item_code = self.finished_good or self.production_item
|
||||
if item_code:
|
||||
self.stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
|
||||
|
||||
def set_operation_id(self):
|
||||
if not (self.work_order and self.operation):
|
||||
return
|
||||
|
||||
if self.operation_id and self.docstatus != 0:
|
||||
return
|
||||
|
||||
operation_rows = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
filters={"parent": self.work_order, "operation": self.operation},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
if self.operation_id:
|
||||
if operation_rows and self.operation_id not in operation_rows:
|
||||
frappe.throw(
|
||||
_("Operation {0} does not belong to the work order {1}").format(
|
||||
bold(self.operation), get_link_to_form("Work Order", self.work_order)
|
||||
)
|
||||
)
|
||||
elif len(operation_rows) == 1:
|
||||
self.operation_id = operation_rows[0]
|
||||
elif operation_rows and self.docstatus == 0:
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
frappe.throw(
|
||||
_("Operation {0} does not belong to the work order {1}").format(
|
||||
bold(self.operation), work_order
|
||||
@@ -1482,17 +1367,13 @@ class JobCard(Document):
|
||||
if not (self.work_order and self.sequence_id):
|
||||
return
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
current_operation_qty = self.get_current_operation_completed_qty()
|
||||
current_operation_qty = 0.0
|
||||
data = self.get_current_operation_data()
|
||||
if data and len(data) > 0:
|
||||
current_operation_qty = flt(data[0].completed_qty)
|
||||
|
||||
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)
|
||||
current_operation_qty += flt(self.total_completed_qty)
|
||||
|
||||
def get_previous_operations(self):
|
||||
previous_operations = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"],
|
||||
@@ -1500,6 +1381,10 @@ class JobCard(Document):
|
||||
order_by="sequence_id, idx",
|
||||
)
|
||||
|
||||
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))
|
||||
)
|
||||
|
||||
if self.track_semi_finished_goods and previous_operations:
|
||||
manufactured_qty = self.get_manufactured_qty_per_operation(
|
||||
[row.name for row in previous_operations]
|
||||
@@ -1508,7 +1393,38 @@ class JobCard(Document):
|
||||
for row in previous_operations:
|
||||
row.manufactured_qty = flt(manufactured_qty.get(row.name))
|
||||
|
||||
return previous_operations
|
||||
for row in previous_operations:
|
||||
if self.track_semi_finished_goods:
|
||||
self.validate_previous_operation_manufactured_qty(row, current_operation_qty)
|
||||
continue
|
||||
|
||||
if not row.completed_qty:
|
||||
frappe.throw(
|
||||
_("{0}, complete the operation {1} before the operation {2}.").format(
|
||||
message, bold(row.operation), bold(self.operation)
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
if row.status != "Completed" and row.completed_qty < current_operation_qty:
|
||||
frappe.throw(
|
||||
_("{0}, complete the operation {1} before the operation {2}.").format(
|
||||
message, bold(row.operation), bold(self.operation)
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
if row.completed_qty < current_operation_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(current_operation_qty)),
|
||||
bold(self.operation),
|
||||
bold(self.get_qty_with_uom(row.completed_qty, row.finished_good)),
|
||||
bold(row.operation),
|
||||
)
|
||||
)
|
||||
|
||||
def get_manufactured_qty_per_operation(self, operation_ids):
|
||||
job_card = frappe.qb.DocType("Job Card")
|
||||
@@ -1527,68 +1443,9 @@ class JobCard(Document):
|
||||
|
||||
return dict(data)
|
||||
|
||||
def get_current_operation_completed_qty(self):
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
current_operation_qty = 0.0
|
||||
data = self.get_current_operation_data()
|
||||
if data and len(data) > 0:
|
||||
current_operation_qty = flt(data[0].completed_qty)
|
||||
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
|
||||
manufactured_qty = flt(row.manufactured_qty)
|
||||
|
||||
current_operation_qty += flt(self.total_completed_qty)
|
||||
|
||||
data = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
fields=["operation", "status", "completed_qty", "sequence_id"],
|
||||
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
|
||||
order_by="sequence_id, idx",
|
||||
)
|
||||
|
||||
<<<<<<< HEAD
|
||||
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))
|
||||
)
|
||||
|
||||
for row in data:
|
||||
if not row.completed_qty:
|
||||
frappe.throw(
|
||||
_("{0}, complete the operation {1} before the operation {2}.").format(
|
||||
message, bold(row.operation), bold(self.operation)
|
||||
),
|
||||
OperationSequenceError,
|
||||
=======
|
||||
if row.completed_qty < current_operation_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(current_operation_qty)),
|
||||
bold(self.operation),
|
||||
bold(self.get_qty_with_uom(row.completed_qty, row.finished_good)),
|
||||
bold(row.operation),
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
)
|
||||
|
||||
if row.status != "Completed" and row.completed_qty < current_operation_qty:
|
||||
frappe.throw(
|
||||
_("{0}, complete the operation {1} before the operation {2}.").format(
|
||||
message, bold(row.operation), bold(self.operation)
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
<<<<<<< HEAD
|
||||
if row.completed_qty < current_operation_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
|
||||
).format(
|
||||
bold(current_operation_qty),
|
||||
bold(self.operation),
|
||||
bold(row.completed_qty),
|
||||
bold(row.operation),
|
||||
)
|
||||
)
|
||||
=======
|
||||
if not manufactured_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
@@ -1614,7 +1471,6 @@ class JobCard(Document):
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
|
||||
def validate_work_order(self):
|
||||
if self.is_work_order_closed():
|
||||
@@ -1744,8 +1600,17 @@ class JobCard(Document):
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
self.set_for_quantity(kwargs)
|
||||
self.validate_complete_job_card_qty(kwargs)
|
||||
|
||||
def set_for_quantity(self, kwargs):
|
||||
"""Qty to Manufacture of the completion dialog covers the current cycle only,
|
||||
so the qty completed by the earlier cycles of this job card is kept."""
|
||||
if not flt(kwargs.for_quantity):
|
||||
return
|
||||
|
||||
self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity)
|
||||
|
||||
def validate_docstatus(self):
|
||||
if self.docstatus == 2:
|
||||
frappe.throw(_("Cancelled Job Card cannot be processed."))
|
||||
@@ -1763,34 +1628,11 @@ class JobCard(Document):
|
||||
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) > self.for_quantity:
|
||||
frappe.throw(_("Pending quantity cannot be greater than the for quantity."))
|
||||
|
||||
self.validate_completion_qty_split(kwargs)
|
||||
|
||||
self.pending_qty = flt(kwargs.pending_qty)
|
||||
self.process_loss_qty = flt(kwargs.process_loss_qty)
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
def validate_completion_qty_split(self, kwargs):
|
||||
if not flt(kwargs.for_quantity):
|
||||
return
|
||||
|
||||
precision = self.precision("total_completed_qty")
|
||||
accounted_qty = flt(kwargs.qty) + flt(kwargs.pending_qty) + flt(kwargs.process_loss_qty)
|
||||
|
||||
if flt(accounted_qty, precision) == flt(kwargs.for_quantity, precision):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Completed Quantity ({0}), Pending Quantity ({1}) and Process Loss Quantity ({2}) must add up to the Qty to Manufacture ({3})."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(kwargs.qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.pending_qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.process_loss_qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.for_quantity)),
|
||||
)
|
||||
)
|
||||
|
||||
def add_completion_time_logs(self, kwargs):
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
if kwargs.end_time:
|
||||
self.add_time_logs(
|
||||
to_time=kwargs.end_time,
|
||||
@@ -1816,25 +1658,49 @@ class JobCard(Document):
|
||||
_("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name))
|
||||
)
|
||||
|
||||
def validate_completion_qty_split(self, kwargs):
|
||||
if not flt(kwargs.for_quantity):
|
||||
return
|
||||
|
||||
precision = self.precision("total_completed_qty")
|
||||
accounted_qty = flt(
|
||||
flt(kwargs.qty, precision)
|
||||
+ flt(kwargs.pending_qty, precision)
|
||||
+ flt(kwargs.process_loss_qty, precision)
|
||||
)
|
||||
|
||||
if flt(accounted_qty, precision) == flt(kwargs.for_quantity, precision):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Completed Quantity ({0}), Pending Quantity ({1}) and Process Loss Quantity ({2}) must add up to the Qty to Manufacture ({3})."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(kwargs.qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.pending_qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.process_loss_qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.for_quantity)),
|
||||
)
|
||||
)
|
||||
|
||||
def get_consumed_process_loss(self):
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.process_loss_qty))
|
||||
.where((table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1))
|
||||
)
|
||||
return query.run()[0][0] or 0
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
|
||||
def get_consumed_process_loss():
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.process_loss_qty))
|
||||
.where(
|
||||
(table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1)
|
||||
)
|
||||
)
|
||||
return query.run()[0][0] or 0
|
||||
|
||||
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
|
||||
|
||||
consumed_process_loss = self.get_consumed_process_loss()
|
||||
ste = ManufactureEntry(
|
||||
{
|
||||
"for_quantity": self.for_quantity - self.manufactured_qty,
|
||||
"process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0),
|
||||
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss,
|
||||
"process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0),
|
||||
"job_card": self.name,
|
||||
"skip_material_transfer": self.skip_material_transfer,
|
||||
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.utils.data import add_to_date, now, today
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import (
|
||||
JobCardOverTransferError,
|
||||
OperationMismatchError,
|
||||
OperationSequenceError,
|
||||
OverlapError,
|
||||
make_corrective_job_card,
|
||||
make_material_request,
|
||||
@@ -888,8 +889,6 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(wo_doc.process_loss_qty, 2)
|
||||
self.assertEqual(wo_doc.status, "Completed")
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
def get_first_job_card(self, work_order):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
@@ -966,7 +965,6 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 5)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 0)
|
||||
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
def test_op_cost_calculation(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
create_routing,
|
||||
@@ -1344,6 +1342,440 @@ class TestJobCard(ERPNextTestSuite):
|
||||
8,
|
||||
)
|
||||
|
||||
def test_semi_fg_process_loss_rolls_up_to_work_order(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Process Loss Rollup RM 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Process Loss Rollup FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
|
||||
|
||||
operation = {
|
||||
"operation": "Process Loss Rollup Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
|
||||
make_workstation(operation)
|
||||
make_operation(operation)
|
||||
fg_bom.append("operations", operation)
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=10,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order.name},
|
||||
order_by="sequence_id, creation",
|
||||
limit=1,
|
||||
pluck="name",
|
||||
)[0],
|
||||
)
|
||||
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=8,
|
||||
for_quantity=10,
|
||||
pending_qty=0,
|
||||
process_loss_qty=2,
|
||||
end_time="2024-05-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 2)
|
||||
|
||||
job_card.submit()
|
||||
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
self.assertEqual(
|
||||
flt(
|
||||
frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "process_loss_qty")
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.produced_qty), 8)
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
self.assertEqual(work_order.status, "Completed")
|
||||
|
||||
def test_semi_fg_process_loss_of_an_intermediate_operation_rolls_up_to_work_order(self):
|
||||
"""Loss booked by an earlier operation shrinks what the final operation can produce,
|
||||
so it has to show up on the work order even though the final operation loses nothing."""
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Intermediate Loss RM 1", {"is_stock_item": 1}).name
|
||||
sfg = make_item("Intermediate Loss SFG 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Intermediate Loss FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
|
||||
operations = [
|
||||
{
|
||||
"operation": "Intermediate Loss Op A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"sequence_id": 1,
|
||||
},
|
||||
{
|
||||
"operation": "Intermediate Loss Op B",
|
||||
"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": sfg, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=10,
|
||||
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=rm, target=warehouse, qty=100, 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",
|
||||
),
|
||||
)
|
||||
|
||||
jc_a = get_job_card("Intermediate Loss Op A")
|
||||
jc_a.append("time_logs", {"from_time": "2024-06-01 08:00:00"})
|
||||
jc_a.save()
|
||||
jc_a.complete_job_card(
|
||||
qty=8, for_quantity=10, pending_qty=0, process_loss_qty=2, end_time="2024-06-01 09:00:00"
|
||||
)
|
||||
jc_a.reload()
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
|
||||
jc_b = get_job_card("Intermediate Loss Op B")
|
||||
jc_b.for_quantity = 8
|
||||
for row in jc_b.items:
|
||||
row.required_qty = 8
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-06-02 08:00:00", "to_time": "2024-06-02 09:00:00", "completed_qty": 8},
|
||||
)
|
||||
jc_b.save()
|
||||
jc_b.submit()
|
||||
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.produced_qty), 8)
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
self.assertEqual(work_order.status, "Completed")
|
||||
|
||||
def test_semi_fg_pending_qty_is_left_to_another_job_card(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Pending Qty RM 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Pending Qty FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
|
||||
|
||||
operation = {
|
||||
"operation": "Pending Qty Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
|
||||
make_workstation(operation)
|
||||
make_operation(operation)
|
||||
fg_bom.append("operations", operation)
|
||||
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,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order.name},
|
||||
order_by="sequence_id, creation",
|
||||
limit=1,
|
||||
pluck="name",
|
||||
)[0],
|
||||
)
|
||||
job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=3,
|
||||
for_quantity=5,
|
||||
pending_qty=2,
|
||||
process_loss_qty=0,
|
||||
end_time="2024-04-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.for_quantity), 5)
|
||||
self.assertEqual(flt(job_card.pending_qty), 2)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 0)
|
||||
|
||||
job_card.submit()
|
||||
self.assertEqual(job_card.status, "Work In Progress")
|
||||
|
||||
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
|
||||
finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item)
|
||||
self.assertEqual(flt(finished_item.qty), 3)
|
||||
manufacturing_entry.submit()
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.manufactured_qty), 3)
|
||||
self.assertEqual(job_card.status, "Completed")
|
||||
|
||||
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):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -1481,6 +1913,299 @@ class TestJobCard(ERPNextTestSuite):
|
||||
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
|
||||
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
|
||||
|
||||
def test_manufacture_entry_process_loss_not_taken_from_previous_operation(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("PL Scope RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item("PL Scope RM 2", {"is_stock_item": 1}).name
|
||||
sfg = make_item("PL Scope SFG 1", {"is_stock_item": 1}).name
|
||||
fg1 = make_item("PL Scope FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg1,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
operation1 = {
|
||||
"operation": "PL Scope Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"finished_good_qty": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
operation2 = {
|
||||
"operation": "PL Scope Op B",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg1,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation1)
|
||||
make_operation(operation1)
|
||||
make_operation(operation2)
|
||||
fg_bom.append("operations", operation1)
|
||||
fg_bom.append("operations", operation2)
|
||||
fg_bom.append("items", {"item_code": rm2, "qty": 1})
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg1,
|
||||
qty=5,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
)
|
||||
|
||||
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)
|
||||
make_stock_entry(item_code=sfg, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
jc_a = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op A"}, "name"
|
||||
),
|
||||
)
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_a.pending_qty = 0
|
||||
jc_a.process_loss_qty = 2
|
||||
jc_a.submit()
|
||||
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
|
||||
me_a.submit()
|
||||
self.assertEqual(flt(me_a.process_loss_qty), 2.0)
|
||||
|
||||
jc_b = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op B"}, "name"
|
||||
),
|
||||
)
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 2
|
||||
jc_b.submit()
|
||||
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
|
||||
# operation A's loss must not leak into operation B's entry
|
||||
self.assertEqual(flt(me_b.process_loss_qty), 0.0)
|
||||
fg_row = next(row for row in me_b.items if row.is_finished_item)
|
||||
self.assertEqual(flt(fg_row.qty), 3.0)
|
||||
me_b.submit()
|
||||
|
||||
def make_semi_fg_work_order(self, prefix, qty=5):
|
||||
"""Two-operation semi FG work order: Op A makes the SFG from RM 1, final Op B
|
||||
consumes it. Both operations skip material transfer; stock is pre-seeded."""
|
||||
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(f"{prefix} RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item(f"{prefix} RM 2", {"is_stock_item": 1}).name
|
||||
sfg = make_item(f"{prefix} SFG 1", {"is_stock_item": 1}).name
|
||||
fg1 = make_item(f"{prefix} FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg1,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
operation1 = {
|
||||
"operation": f"{prefix} Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"finished_good_qty": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
operation2 = {
|
||||
"operation": f"{prefix} Op B",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg1,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation1)
|
||||
make_operation(operation1)
|
||||
make_operation(operation2)
|
||||
fg_bom.append("operations", operation1)
|
||||
fg_bom.append("operations", operation2)
|
||||
fg_bom.append("items", {"item_code": rm2, "qty": 1})
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg1,
|
||||
qty=qty,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
)
|
||||
|
||||
for item_code in (rm1, rm2, sfg):
|
||||
make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
return work_order
|
||||
|
||||
def get_semi_fg_job_card(self, work_order, operation):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"),
|
||||
)
|
||||
|
||||
def test_partial_manufacture_entry_then_finish(self):
|
||||
work_order = self.make_semi_fg_work_order("PL Partial")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL Partial Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
jc_b = self.get_semi_fg_job_card(work_order, "PL Partial Op B")
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 0
|
||||
jc_b.process_loss_qty = 2
|
||||
jc_b.submit()
|
||||
|
||||
# book 1 of the 3 finished units now; the full process loss goes with this first entry,
|
||||
# so it accounts for 3 of 5 and its materials are trimmed to the same share
|
||||
first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
fg_row = next(row for row in first.items if row.is_finished_item)
|
||||
fg_row.qty = 1
|
||||
for row in first.items:
|
||||
if row.s_warehouse and not row.is_finished_item:
|
||||
row.qty = flt(row.qty) * 3 / 5
|
||||
first.save()
|
||||
first.submit()
|
||||
|
||||
# the follow-up entry must be generated net of the already-booked loss and still submit
|
||||
jc_b.reload()
|
||||
second = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
fg_row = next(row for row in second.items if row.is_finished_item)
|
||||
self.assertEqual(flt(fg_row.qty), 2.0)
|
||||
self.assertEqual(flt(second.process_loss_qty), 0.0)
|
||||
second.submit()
|
||||
|
||||
jc_b.reload()
|
||||
self.assertEqual(flt(jc_b.manufactured_qty), 3.0)
|
||||
|
||||
# across both entries, consumption adds up to the job card's requirement of 5, no more
|
||||
consumed = frappe.get_all(
|
||||
"Stock Entry Detail",
|
||||
filters={"parent": ["in", [first.name, second.name]], "s_warehouse": ["is", "set"]},
|
||||
fields=["item_code", {"SUM": "qty", "as": "qty"}],
|
||||
group_by="item_code",
|
||||
)
|
||||
self.assertTrue(consumed)
|
||||
for row in consumed:
|
||||
self.assertEqual(flt(row.qty), 5.0, f"{row.item_code} mis-consumed across partial entries")
|
||||
|
||||
def test_update_after_submit_keeps_manufacture_entry_intact(self):
|
||||
work_order = self.make_semi_fg_work_order("PL Update")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL Update Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_a.pending_qty = 0
|
||||
jc_a.process_loss_qty = 2
|
||||
jc_a.submit()
|
||||
|
||||
entry = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
|
||||
entry.submit()
|
||||
|
||||
if not frappe.db.exists("Print Heading", "_Test SFG Heading"):
|
||||
frappe.get_doc({"doctype": "Print Heading", "print_heading": "_Test SFG Heading"}).insert()
|
||||
|
||||
entry.reload()
|
||||
entry.select_print_heading = "_Test SFG Heading"
|
||||
entry.save()
|
||||
|
||||
entry.reload()
|
||||
self.assertEqual(flt(entry.process_loss_qty), 2.0)
|
||||
|
||||
def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self):
|
||||
work_order = self.make_semi_fg_work_order("PL NoBom")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL NoBom Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
# Op B has no operation BOM, so its entries carry no For Quantity to validate against
|
||||
jc_b = self.get_semi_fg_job_card(work_order, "PL NoBom Op B")
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 0
|
||||
jc_b.process_loss_qty = 2
|
||||
jc_b.submit()
|
||||
|
||||
draft_one = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
draft_two = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
|
||||
draft_one.submit()
|
||||
|
||||
stale = frappe.get_doc("Stock Entry", draft_two.name)
|
||||
self.assertRaises(frappe.ValidationError, stale.submit)
|
||||
|
||||
def test_semi_fg_auto_pull_with_uom_conversion(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -1896,6 +2621,54 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(s.additional_costs[2].amount, 480)
|
||||
self.assertEqual(s.additional_costs[3].amount, 480)
|
||||
|
||||
def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.track_semi_finished_goods = 1
|
||||
jc.skip_material_transfer = 1
|
||||
jc.for_quantity = 10
|
||||
jc.transferred_qty = 0
|
||||
jc.append("items", {"item_code": "_Test Item"})
|
||||
|
||||
jc.validate_transfer_qty()
|
||||
|
||||
# with transfer enabled, a legacy card without an FG item keeps the strict check
|
||||
jc.skip_material_transfer = 0
|
||||
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
|
||||
|
||||
jc.finished_good = "_Test Item"
|
||||
jc.validate_transfer_qty()
|
||||
|
||||
jc.finished_good = None
|
||||
jc.track_semi_finished_goods = 0
|
||||
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
|
||||
|
||||
def test_qty_in_messages_carries_the_uom(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.stock_uom = "Nos"
|
||||
|
||||
self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos")
|
||||
self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos")
|
||||
|
||||
def test_completion_qty_split_must_add_up(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.for_quantity = 5
|
||||
|
||||
jc.validate_completion_qty_split(
|
||||
frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0)
|
||||
)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
jc.validate_completion_qty_split,
|
||||
frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0),
|
||||
)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
jc.validate_completion_qty_split,
|
||||
frappe._dict(for_quantity=1, qty=0.3334, pending_qty=0.3334, process_loss_qty=0.3334),
|
||||
)
|
||||
|
||||
|
||||
def create_bom_with_multiple_operations():
|
||||
"Create a BOM with multiple operations and Material Transfer against Job Card"
|
||||
@@ -1958,101 +2731,3 @@ def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required):
|
||||
bom.append("items", {"item_code": raw_item, "qty": 1})
|
||||
bom.submit()
|
||||
return bom.name
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
|
||||
class TestJobCardLogic(ERPNextTestSuite):
|
||||
"""Field-level validations and pure quantity/capacity helpers, exercised on the
|
||||
document directly so they don't need a Work Order / BOM (the integration suite does)."""
|
||||
|
||||
def test_processing_a_submitted_or_cancelled_card_is_blocked(self):
|
||||
submitted = frappe.new_doc("Job Card")
|
||||
submitted.docstatus = 1
|
||||
self.assertRaises(frappe.ValidationError, submitted.validate_docstatus)
|
||||
|
||||
cancelled = frappe.new_doc("Job Card")
|
||||
cancelled.docstatus = 2
|
||||
self.assertRaises(frappe.ValidationError, cancelled.validate_docstatus)
|
||||
|
||||
def test_complete_job_card_qty_guards(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.for_quantity = 5
|
||||
jc.validate_complete_job_card_qty(frappe._dict(pending_qty=3)) # within range -> passes
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=-1)
|
||||
)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(process_loss_qty=-1)
|
||||
)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=10)
|
||||
)
|
||||
|
||||
def test_qty_in_messages_carries_the_uom(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.stock_uom = "Nos"
|
||||
|
||||
self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos")
|
||||
self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos")
|
||||
|
||||
def test_completion_qty_split_must_add_up(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.for_quantity = 5
|
||||
|
||||
# 3 completed + 2 pending + 0 lost == 5 to manufacture -> passes
|
||||
jc.validate_complete_job_card_qty(
|
||||
frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0)
|
||||
)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
jc.validate_complete_job_card_qty,
|
||||
frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0),
|
||||
)
|
||||
|
||||
def test_completed_qty_must_reconcile_with_for_quantity(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.for_quantity = 10
|
||||
jc.total_completed_qty = 6
|
||||
jc.process_loss_qty = 0
|
||||
jc.pending_qty = 0
|
||||
# 6 + 0 + 0 != 10 -> throws
|
||||
self.assertRaises(frappe.ValidationError, jc.validate_completed_qty_matches_for_quantity)
|
||||
# completed + loss + pending == for_quantity -> passes
|
||||
jc.pending_qty = 4
|
||||
jc.validate_completed_qty_matches_for_quantity()
|
||||
|
||||
def test_set_process_loss(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.for_quantity = 10
|
||||
jc.total_completed_qty = 6
|
||||
jc.pending_qty = 1
|
||||
jc.set_process_loss()
|
||||
self.assertEqual(jc.process_loss_qty, 3) # 10 - 6 - 1
|
||||
|
||||
# no loss when nothing completed yet
|
||||
nothing_done = frappe.new_doc("Job Card")
|
||||
nothing_done.for_quantity = 10
|
||||
nothing_done.total_completed_qty = 0
|
||||
nothing_done.set_process_loss()
|
||||
self.assertEqual(nothing_done.process_loss_qty, 0)
|
||||
|
||||
def test_capacity_overlap_detection(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
sequential = [
|
||||
{"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"},
|
||||
{"from_time": "2026-01-01 11:00:00", "to_time": "2026-01-01 12:00:00"},
|
||||
]
|
||||
overlapping = [
|
||||
{"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"},
|
||||
{"from_time": "2026-01-01 10:30:00", "to_time": "2026-01-01 11:30:00"},
|
||||
]
|
||||
# sequential logs share one capacity slot; overlapping logs need two
|
||||
self.assertEqual(len(jc.get_alloted_capacity(sequential)), 1)
|
||||
self.assertEqual(len(jc.get_alloted_capacity(overlapping)), 2)
|
||||
# capacity 1 overlaps with any log; capacity 2 only when both slots are taken
|
||||
self.assertTrue(jc.has_overlap(1, sequential))
|
||||
self.assertFalse(jc.has_overlap(2, sequential))
|
||||
self.assertTrue(jc.has_overlap(2, overlapping))
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
|
||||
@@ -4839,6 +4839,24 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
# generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units)
|
||||
self.assertEqual(flt(row.qty, 6), 3.0)
|
||||
|
||||
def test_wip_warehouse_required_when_tracking_semi_finished_goods(self):
|
||||
wo = frappe.new_doc("Work Order")
|
||||
wo.track_semi_finished_goods = 1
|
||||
wo.skip_transfer = 0
|
||||
wo.fg_warehouse = "_Test Warehouse 1 - _TC"
|
||||
|
||||
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
|
||||
|
||||
wo.wip_warehouse = "_Test Warehouse - _TC"
|
||||
wo.validate_warehouse()
|
||||
|
||||
# the top-level target warehouse stays optional; operations may carry their own
|
||||
wo.fg_warehouse = None
|
||||
wo.validate_warehouse()
|
||||
|
||||
wo.track_semi_finished_goods = 0
|
||||
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
|
||||
|
||||
|
||||
def get_reserved_entries(voucher_no, warehouse=None):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Work-in-Progress Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods",
|
||||
"mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -739,7 +739,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 21:35:34.175667",
|
||||
"modified": "2026-08-08 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -883,6 +883,12 @@ class WorkOrder(Document):
|
||||
return flt(query.run()[0][0])
|
||||
|
||||
def set_process_loss_qty(self):
|
||||
self.db_set("process_loss_qty", self._process_loss_qty())
|
||||
|
||||
def _process_loss_qty(self):
|
||||
if self.track_semi_finished_goods:
|
||||
return flt(sum(flt(row.process_loss_qty) for row in self.operations))
|
||||
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
process_loss_qty = (
|
||||
frappe.qb.from_(table)
|
||||
@@ -892,7 +898,7 @@ class WorkOrder(Document):
|
||||
)
|
||||
).run()[0][0]
|
||||
|
||||
self.db_set("process_loss_qty", flt(process_loss_qty))
|
||||
return flt(process_loss_qty)
|
||||
|
||||
def update_production_plan_status(self):
|
||||
production_plan = frappe.get_doc("Production Plan", self.production_plan)
|
||||
@@ -915,12 +921,9 @@ class WorkOrder(Document):
|
||||
production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item)
|
||||
|
||||
def validate_warehouse(self):
|
||||
if self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
if not self.wip_warehouse and not self.skip_transfer:
|
||||
frappe.throw(_("Work-in-Progress Warehouse is required before Submit"))
|
||||
if not self.fg_warehouse:
|
||||
if not self.fg_warehouse and not self.track_semi_finished_goods:
|
||||
frappe.throw(_("Target Warehouse is required before Submit"))
|
||||
|
||||
def before_submit(self):
|
||||
|
||||
@@ -1,834 +0,0 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Order
|
||||
from frappe.query_builder.functions import Count, Date
|
||||
from frappe.utils import cint, flt, get_datetime, getdate, now_datetime, time_diff_in_seconds
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.manufacturing.doctype.workstation.workstation import (
|
||||
get_status_color,
|
||||
get_time_logs,
|
||||
)
|
||||
from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import (
|
||||
get_template_details,
|
||||
)
|
||||
|
||||
JOB_CARD_FIELDS = [
|
||||
"name",
|
||||
"docstatus",
|
||||
"production_item",
|
||||
"work_order",
|
||||
"operation",
|
||||
"total_completed_qty",
|
||||
"for_quantity",
|
||||
"process_loss_qty",
|
||||
"stock_uom",
|
||||
"finished_good",
|
||||
"transferred_qty",
|
||||
"status",
|
||||
"expected_start_date",
|
||||
"expected_end_date",
|
||||
"time_required",
|
||||
"wip_warehouse",
|
||||
"skip_material_transfer",
|
||||
"backflush_from_wip_warehouse",
|
||||
"is_paused",
|
||||
"manufactured_qty",
|
||||
"is_subcontracted",
|
||||
"workstation",
|
||||
"sequence_id",
|
||||
"bom_no",
|
||||
"operation_id",
|
||||
"quality_inspection",
|
||||
"quality_inspection_template",
|
||||
]
|
||||
|
||||
TODAY_SESSION_FIELDS = [
|
||||
"name",
|
||||
"docstatus",
|
||||
"production_item",
|
||||
"finished_good",
|
||||
"operation",
|
||||
"total_completed_qty",
|
||||
"for_quantity",
|
||||
"process_loss_qty",
|
||||
"total_time_in_mins",
|
||||
"status",
|
||||
"modified",
|
||||
]
|
||||
|
||||
# Roles that unlock the Shop Floor manager board (work-order overview). Anyone else gets the
|
||||
# operator view. System Manager is included so admins always see the full picture.
|
||||
MANAGER_ROLES = {"Shop Floor Manager", "Manufacturing Manager", "System Manager"}
|
||||
|
||||
# Maps the manager buckets to the underlying Work Order statuses. "open" spans pending AND
|
||||
# in-progress: starting a job card flips the Work Order to In Process, and separate tabs made
|
||||
# it jump tabs on the next refresh — the operator would lose the card they were working on.
|
||||
WORK_ORDER_STATUS_GROUPS = {
|
||||
"open": ["In Process", "Not Started", "Submitted", "Stock Reserved", "Stock Partially Reserved"],
|
||||
"completed": ["Completed"],
|
||||
}
|
||||
|
||||
WORK_ORDER_FIELDS = [
|
||||
"name",
|
||||
"production_item",
|
||||
"item_name",
|
||||
"qty",
|
||||
"produced_qty",
|
||||
"status",
|
||||
"planned_start_date",
|
||||
"sales_order",
|
||||
"bom_no",
|
||||
]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def submit_job_card(job_card: str):
|
||||
"""Submit a draft job card whose quantity has already been recorded via End Session."""
|
||||
frappe.has_permission("Job Card", "submit", throw=True)
|
||||
jc = frappe.get_doc("Job Card", job_card)
|
||||
if jc.docstatus == 0:
|
||||
jc.submit()
|
||||
return {"name": jc.name, "docstatus": jc.docstatus}
|
||||
|
||||
|
||||
def _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time):
|
||||
"""Record the session qty + close the active time log via Job Card's complete_job_card.
|
||||
|
||||
auto_submit=0 so the backend doesn't auto-create+submit a Manufacture Stock Entry — that's
|
||||
a separate manual step driven by the post-submit prompt. Returns the reloaded doc.
|
||||
"""
|
||||
frappe.has_permission("Job Card", "write", throw=True)
|
||||
|
||||
doc = frappe.get_doc("Job Card", job_card)
|
||||
doc.run_method(
|
||||
"complete_job_card",
|
||||
qty=flt(qty),
|
||||
for_quantity=flt(for_quantity),
|
||||
pending_qty=flt(pending_qty),
|
||||
process_loss_qty=flt(process_loss_qty),
|
||||
end_time=end_time,
|
||||
auto_submit=0,
|
||||
)
|
||||
doc.reload()
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_and_continue(
|
||||
job_card: str,
|
||||
qty: float,
|
||||
for_quantity: float,
|
||||
pending_qty: float,
|
||||
process_loss_qty: float,
|
||||
end_time: str,
|
||||
):
|
||||
"""Record the session qty + close the time log, then mark the JC as paused
|
||||
so the MES keeps it in the active slot for the next session."""
|
||||
frappe.has_permission("Job Card", "write", throw=True)
|
||||
doc = _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time)
|
||||
if doc.docstatus == 0:
|
||||
doc.db_set("is_paused", 1)
|
||||
return {"name": doc.name}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def complete_and_submit(
|
||||
job_card: str,
|
||||
qty: float,
|
||||
for_quantity: float,
|
||||
pending_qty: float,
|
||||
process_loss_qty: float,
|
||||
end_time: str,
|
||||
):
|
||||
"""Record the session qty + close the time log + submit the JC."""
|
||||
frappe.has_permission("Job Card", "submit", throw=True)
|
||||
doc = _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time)
|
||||
if doc.docstatus == 0:
|
||||
doc.submit()
|
||||
return {"name": doc.name, "finished_good": doc.finished_good}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_manufacture_stock_entry(job_card: str):
|
||||
"""Build a "Manufacture" Stock Entry for the finished goods and save as draft.
|
||||
|
||||
Mirrors the Job Card form's "Make Stock Entry" button — uses the doc's own
|
||||
make_stock_entry_for_semi_fg_item (purpose="Manufacture", job card linked) rather than
|
||||
the generic make_stock_entry, which would produce a "Material Transfer for Manufacture".
|
||||
Returns the draft SE name so the client can open it in a new tab.
|
||||
"""
|
||||
frappe.has_permission("Job Card", "read", throw=True)
|
||||
frappe.has_permission("Stock Entry", "submit", throw=True)
|
||||
doc = frappe.get_doc("Job Card", job_card)
|
||||
se = doc.make_stock_entry_for_semi_fg_item(auto_submit=False)
|
||||
return {"name": se.get("name")}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_quality_inspection_checklist(job_card: str):
|
||||
"""Template parameters for the inline quality check an operator fills before submitting a
|
||||
job card. Returns the resolved template, its parameter rows, any already-linked inspection,
|
||||
and the item being inspected.
|
||||
"""
|
||||
frappe.has_permission("Job Card", "read", throw=True)
|
||||
|
||||
jc = frappe.db.get_value(
|
||||
"Job Card",
|
||||
job_card,
|
||||
[
|
||||
"quality_inspection",
|
||||
"quality_inspection_template",
|
||||
"operation",
|
||||
"production_item",
|
||||
"finished_good",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
if not jc:
|
||||
frappe.throw(_("Job Card {0} not found").format(job_card))
|
||||
|
||||
template = jc.quality_inspection_template
|
||||
if not template and jc.operation:
|
||||
template = frappe.get_cached_value("Operation", jc.operation, "quality_inspection_template")
|
||||
|
||||
parameters = []
|
||||
for p in get_template_details(template):
|
||||
parameters.append(
|
||||
{
|
||||
"specification": p.specification,
|
||||
"value": p.value,
|
||||
"numeric": cint(p.numeric),
|
||||
"min_value": p.min_value,
|
||||
"max_value": p.max_value,
|
||||
"formula_based_criteria": cint(p.formula_based_criteria),
|
||||
"acceptance_formula": p.acceptance_formula,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"template": template,
|
||||
"parameters": parameters,
|
||||
"existing": jc.quality_inspection,
|
||||
"item_code": jc.finished_good or jc.production_item,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def submit_quality_inspection(job_card: str, readings: str | None = None):
|
||||
"""Create + submit an In-Process Quality Inspection for the job card and link it back, so the
|
||||
standard Job Card.validate_inspection() gate passes when the card is submitted.
|
||||
|
||||
`readings` is a JSON list of {specification, status, reading_value} captured inline. For numeric
|
||||
/ formula parameters the measured value is stored and the Quality Inspection auto-evaluates
|
||||
pass/fail against min/max (or the formula); for qualitative parameters the operator's explicit
|
||||
Accepted/Rejected is taken as authoritative (manual_inspection). The QI's own validation then
|
||||
sets the overall Accepted/Rejected status.
|
||||
"""
|
||||
frappe.has_permission("Job Card", "write", throw=True)
|
||||
frappe.has_permission("Quality Inspection", "submit", throw=True)
|
||||
|
||||
jc = frappe.get_doc("Job Card", job_card)
|
||||
|
||||
# Idempotent: if a submitted inspection is already linked, don't create another.
|
||||
if jc.quality_inspection:
|
||||
existing = frappe.db.get_value(
|
||||
"Quality Inspection", jc.quality_inspection, ["status", "docstatus"], as_dict=True
|
||||
)
|
||||
if existing and existing.docstatus == 1:
|
||||
return {"name": jc.quality_inspection, "status": existing.status}
|
||||
|
||||
template = jc.quality_inspection_template
|
||||
if not template and jc.operation:
|
||||
template = frappe.get_cached_value("Operation", jc.operation, "quality_inspection_template")
|
||||
if not template:
|
||||
frappe.throw(_("No Quality Inspection Template is configured for this operation."))
|
||||
|
||||
reading_map = {r.get("specification"): r for r in (frappe.parse_json(readings) or [])}
|
||||
|
||||
qi = frappe.new_doc("Quality Inspection")
|
||||
qi.inspection_type = "In Process"
|
||||
qi.reference_type = "Job Card"
|
||||
qi.reference_name = job_card
|
||||
qi.item_code = jc.finished_good or jc.production_item
|
||||
qi.bom_no = jc.bom_no
|
||||
qi.quality_inspection_template = template
|
||||
qi.inspected_by = frappe.session.user
|
||||
qi.get_item_specification_details() # load readings from the template
|
||||
|
||||
for reading in qi.readings:
|
||||
entry = reading_map.get(reading.specification)
|
||||
if not entry:
|
||||
continue
|
||||
value = entry.get("reading_value")
|
||||
if reading.numeric or reading.formula_based_criteria:
|
||||
# Measured value → let the Quality Inspection judge it against min/max or the formula.
|
||||
if value not in (None, ""):
|
||||
reading.reading_value = value
|
||||
reading.reading_1 = value
|
||||
else:
|
||||
# Qualitative check → the operator's explicit pass/fail wins.
|
||||
reading.manual_inspection = 1
|
||||
reading.status = entry.get("status") or "Accepted"
|
||||
if value not in (None, ""):
|
||||
reading.reading_value = value
|
||||
|
||||
qi.insert()
|
||||
qi.submit() # validate() → inspect_and_set_status() sets the overall Accepted/Rejected status
|
||||
|
||||
# Link explicitly: the QI's own back-reference matches on production_item, which can differ
|
||||
# from the operation's finished_good, so we set it directly to be safe.
|
||||
jc.db_set("quality_inspection", qi.name)
|
||||
return {"name": qi.name, "status": qi.status}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_shop_floor_context():
|
||||
"""Which experience to render — the manager board or the operator view — plus the
|
||||
signed-in operator's Employee (so Start Job can pre-fill them)."""
|
||||
roles = set(frappe.get_roles())
|
||||
can_manage = bool(roles & MANAGER_ROLES)
|
||||
return {
|
||||
"role_view": "manager" if can_manage else "operator",
|
||||
"can_manage": can_manage,
|
||||
"user_employee": frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name"),
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_work_orders(
|
||||
status_group: str,
|
||||
start: int = 0,
|
||||
page_length: int = 20,
|
||||
search: str | None = None,
|
||||
with_job_cards_only: bool | int = 0,
|
||||
):
|
||||
"""Paginated Work Orders for one manager bucket (open / completed),
|
||||
each decorated with a job-card status breakdown for the card's progress chip.
|
||||
|
||||
When `with_job_cards_only` is set, only Work Orders that have at least one (non-cancelled)
|
||||
Job Card are returned — the board's opt-in "With job cards only" toggle.
|
||||
"""
|
||||
frappe.has_permission("Work Order", "read", throw=True)
|
||||
|
||||
if status_group not in WORK_ORDER_STATUS_GROUPS:
|
||||
frappe.throw(_("Invalid status group: {0}").format(status_group))
|
||||
|
||||
start = cint(start)
|
||||
page_length = cint(page_length) or 20
|
||||
with_job_cards_only = cint(with_job_cards_only)
|
||||
|
||||
# Active buckets show the oldest-planned first (work the floor next); completed shows newest first.
|
||||
order = Order.desc if status_group == "completed" else Order.asc
|
||||
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
query = _apply_work_order_filters(frappe.qb.from_(wo), wo, status_group, search, with_job_cards_only)
|
||||
work_orders = (
|
||||
query.select(*[wo[field] for field in WORK_ORDER_FIELDS])
|
||||
.orderby(wo.planned_start_date, order=order)
|
||||
.limit(page_length)
|
||||
.offset(start)
|
||||
).run(as_dict=True)
|
||||
|
||||
total = _count_work_orders(status_group, search, with_job_cards_only)
|
||||
|
||||
_enrich_work_orders(work_orders)
|
||||
|
||||
return {
|
||||
"work_orders": work_orders,
|
||||
"total": cint(total),
|
||||
"start": start,
|
||||
"page_length": page_length,
|
||||
}
|
||||
|
||||
|
||||
def _has_job_cards_criterion(wo, docstatus):
|
||||
jc = frappe.qb.DocType("Job Card")
|
||||
return ExistsCriterion(
|
||||
frappe.qb.from_(jc).select(jc.name).where((jc.work_order == wo.name) & (jc.docstatus == docstatus))
|
||||
)
|
||||
|
||||
|
||||
def _bucket_criterion(wo, status_group):
|
||||
"""The floor is done with a Work Order once every job card is submitted, even though the
|
||||
Work Order stays "In Process" until the finished goods are received. Such operationally
|
||||
complete orders belong on the Completed tab — not stranded under Pending / In Progress."""
|
||||
open_statuses = WORK_ORDER_STATUS_GROUPS["open"]
|
||||
all_job_cards_done = _has_job_cards_criterion(wo, 1) & _has_job_cards_criterion(wo, 0).negate()
|
||||
if status_group == "completed":
|
||||
return (wo.status == "Completed") | (wo.status.isin(open_statuses) & all_job_cards_done)
|
||||
return wo.status.isin(open_statuses) & (
|
||||
_has_job_cards_criterion(wo, 1).negate() | _has_job_cards_criterion(wo, 0)
|
||||
)
|
||||
|
||||
|
||||
def _apply_work_order_filters(query, wo, status_group, search, with_job_cards_only):
|
||||
"""Shared WHERE clauses for the board's row + count queries (bucket, search, job-card toggle)."""
|
||||
query = query.where((wo.docstatus == 1) & _bucket_criterion(wo, status_group))
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
query = query.where(wo.name.like(like) | wo.production_item.like(like) | wo.item_name.like(like))
|
||||
if with_job_cards_only:
|
||||
jc = frappe.qb.DocType("Job Card")
|
||||
wo_with_job_cards = frappe.qb.from_(jc).select(jc.work_order).where(jc.docstatus < 2)
|
||||
query = query.where(wo.name.isin(wo_with_job_cards))
|
||||
return query
|
||||
|
||||
|
||||
def _count_work_orders(status_group: str, search: str | None, with_job_cards_only: int = 0) -> int:
|
||||
"""Total Work Orders in a bucket (drives pagination), honouring the same filters as the rows."""
|
||||
wo = frappe.qb.DocType("Work Order")
|
||||
query = _apply_work_order_filters(frappe.qb.from_(wo), wo, status_group, search, with_job_cards_only)
|
||||
return cint(query.select(Count("*")).run()[0][0])
|
||||
|
||||
|
||||
def _enrich_work_orders(work_orders: list[dict]) -> None:
|
||||
"""Attach item/workstation image, status colour, % complete and a job-card breakdown to each row."""
|
||||
wo_names = [row.name for row in work_orders]
|
||||
jc_counts = _get_job_card_status_counts(wo_names)
|
||||
workstation_map = _get_current_workstation_map(wo_names)
|
||||
for row in work_orders:
|
||||
row.item_image = (
|
||||
frappe.get_cached_value("Item", row.production_item, "image") if row.production_item else None
|
||||
)
|
||||
row.status_colour = get_status_color(row.status)
|
||||
row.per_completed = round(flt(row.produced_qty) / flt(row.qty) * 100, 1) if flt(row.qty) else 0
|
||||
counts = jc_counts.get(row.name, {})
|
||||
row.job_card_status = counts.get("by_status", {})
|
||||
row.total_operations = counts.get("total", 0)
|
||||
row.completed_operations = counts.get("completed", 0)
|
||||
row.in_progress_operations = counts.get("in_progress", 0)
|
||||
# Two segments for the card's progress bar: green (done) + orange (in progress); the rest
|
||||
# of the track stays grey (pending / not started).
|
||||
row.per_operations = (
|
||||
round(row.completed_operations / row.total_operations * 100, 1) if row.total_operations else 0
|
||||
)
|
||||
row.per_in_progress = (
|
||||
round(row.in_progress_operations / row.total_operations * 100, 1) if row.total_operations else 0
|
||||
)
|
||||
# Current/active operation's workstation (name + Active-Status image) for the card header.
|
||||
workstation = workstation_map.get(row.name, {})
|
||||
row.workstation = workstation.get("workstation")
|
||||
row.workstation_name = workstation.get("workstation_name")
|
||||
row.workstation_image = workstation.get("image")
|
||||
row.current_operation = workstation.get("operation")
|
||||
|
||||
|
||||
def _get_current_workstation_map(wo_names: list[str]) -> dict[str, dict]:
|
||||
"""Map each Work Order to its current operation's workstation (name + Active-Status image).
|
||||
|
||||
The "current" operation is the first not-yet-completed operation in the routing (by idx);
|
||||
if every operation is complete, the last one is used so finished cards still show a workstation.
|
||||
"""
|
||||
if not wo_names:
|
||||
return {}
|
||||
|
||||
operations = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
filters={"parent": ["in", wo_names]},
|
||||
fields=["parent", "idx", "operation", "status", "workstation"],
|
||||
order_by="parent, idx",
|
||||
)
|
||||
|
||||
ops_by_wo: dict[str, list] = {}
|
||||
for op in operations:
|
||||
ops_by_wo.setdefault(op.parent, []).append(op)
|
||||
|
||||
chosen_by_wo = {}
|
||||
workstations = set()
|
||||
for wo_name, ops in ops_by_wo.items():
|
||||
current = next((op for op in ops if (op.status or "") != "Completed"), ops[-1])
|
||||
if current.workstation:
|
||||
chosen_by_wo[wo_name] = current
|
||||
workstations.add(current.workstation)
|
||||
|
||||
ws_details = {}
|
||||
if workstations:
|
||||
for ws in frappe.get_all(
|
||||
"Workstation",
|
||||
filters={"name": ["in", list(workstations)]},
|
||||
fields=["name", "workstation_name", "on_status_image"],
|
||||
):
|
||||
ws_details[ws.name] = ws
|
||||
|
||||
result = {}
|
||||
for wo_name, op in chosen_by_wo.items():
|
||||
detail = ws_details.get(op.workstation, {})
|
||||
result[wo_name] = {
|
||||
"workstation": op.workstation,
|
||||
"workstation_name": detail.get("workstation_name") or op.workstation,
|
||||
"image": detail.get("on_status_image"),
|
||||
"operation": op.operation,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _get_job_card_status_counts(wo_names: list[str]) -> dict[str, dict]:
|
||||
"""One batched query → {work_order: {by_status: {status: n}, total, completed}} for the WO cards."""
|
||||
if not wo_names:
|
||||
return {}
|
||||
|
||||
rows = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": ["in", wo_names], "docstatus": ["<", 2]},
|
||||
fields=["work_order", "status"],
|
||||
)
|
||||
result: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
entry = result.setdefault(
|
||||
row.work_order, {"by_status": {}, "total": 0, "completed": 0, "in_progress": 0}
|
||||
)
|
||||
status = "Not Started" if (row.status or "Open") == "Open" else row.status
|
||||
entry["by_status"][status] = entry["by_status"].get(status, 0) + 1
|
||||
entry["total"] += 1
|
||||
# "To Manufacture" = operation done, only the Manufacture Stock Entry is pending — count it
|
||||
# as completed so the work order's progress bar reflects the finished operation.
|
||||
if status in ("Completed", "Submitted", "To Manufacture"):
|
||||
entry["completed"] += 1
|
||||
elif status == "Work In Progress":
|
||||
entry["in_progress"] += 1
|
||||
return result
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_data(workstation: str | None = None, work_order: str | None = None):
|
||||
"""
|
||||
Returns job-card data for the Shop Floor page.
|
||||
|
||||
When `work_order` is set it wins over `workstation` and the result spans every
|
||||
operation of that Work Order (including subcontracted job cards). When only
|
||||
`workstation` is set, the result is the open + in-progress job cards for that
|
||||
workstation, excluding subcontracted.
|
||||
"""
|
||||
if not (workstation or work_order):
|
||||
return {"job_cards": [], "capacity": 1, "mode": None}
|
||||
if not frappe.has_permission("Job Card", "read"):
|
||||
return {"job_cards": [], "capacity": 1, "mode": None}
|
||||
|
||||
filters, mode = _build_job_card_filters(workstation, work_order)
|
||||
jc_data = _fetch_job_cards(filters, mode)
|
||||
_enrich_job_cards(jc_data)
|
||||
|
||||
capacity, oee = 1, None
|
||||
if mode == "workstation":
|
||||
capacity = frappe.db.get_value("Workstation", workstation, "production_capacity") or 1
|
||||
oee = get_workstation_oee(workstation)
|
||||
|
||||
return {
|
||||
"job_cards": jc_data,
|
||||
"capacity": capacity,
|
||||
"mode": mode,
|
||||
"oee": oee,
|
||||
"user_employee": frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name"),
|
||||
"today_sessions": get_today_sessions(workstation, work_order),
|
||||
}
|
||||
|
||||
|
||||
def _build_job_card_filters(workstation, work_order):
|
||||
"""Filters + mode for the job-card query. work_order spans all ops; workstation is operator view."""
|
||||
filters = {"docstatus": ("<", 2)}
|
||||
if work_order:
|
||||
filters["work_order"] = work_order
|
||||
return filters, "work_order"
|
||||
|
||||
filters["workstation"] = workstation
|
||||
filters["is_subcontracted"] = 0
|
||||
filters["status"] = ["!=", "Stopped"]
|
||||
return filters, "workstation"
|
||||
|
||||
|
||||
def _fetch_job_cards(filters, mode):
|
||||
"""Job cards matching filters. In workstation mode only drafts matter — submitted JCs are
|
||||
done from MES's perspective and missed ones are picked up via the standard Job Card list.
|
||||
|
||||
In work_order mode the whole routing is shown (incl. completed/submitted job cards), ordered
|
||||
by the operation sequence so the operator reads them in manufacturing order.
|
||||
"""
|
||||
order_by = (
|
||||
"sequence_id asc, expected_start_date, expected_end_date"
|
||||
if mode == "work_order"
|
||||
else "expected_start_date, expected_end_date"
|
||||
)
|
||||
# Drafts are the operator's working set; submitted "To Manufacture" cards are also kept so
|
||||
# the station shows what still needs a Manufacture Stock Entry (its own section, client-side).
|
||||
# This must be part of the query, not a post-filter: a busy workstation's history would
|
||||
# otherwise fill the row limit with old submitted cards and hide the active drafts.
|
||||
or_filters = [["docstatus", "=", 0], ["status", "=", "To Manufacture"]] if mode == "workstation" else None
|
||||
return frappe.get_all(
|
||||
"Job Card",
|
||||
fields=JOB_CARD_FIELDS,
|
||||
filters=filters,
|
||||
or_filters=or_filters,
|
||||
order_by=order_by,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
|
||||
def _enrich_job_cards(jc_data):
|
||||
"""Decorate every row with display + material-availability data for the page."""
|
||||
job_card_names = [row.name for row in jc_data]
|
||||
time_logs = get_time_logs(job_card_names) if job_card_names else {}
|
||||
allow_excess_transfer = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer")
|
||||
for row in jc_data:
|
||||
_enrich_job_card_row(row, time_logs, allow_excess_transfer)
|
||||
|
||||
|
||||
def _enrich_job_card_row(row, time_logs, allow_excess_transfer):
|
||||
"""Attach status label, item image/uom, time logs and material availability to one row."""
|
||||
if row.status == "Open":
|
||||
row.status = "Not Started"
|
||||
|
||||
item_code = row.finished_good or row.production_item
|
||||
row.fg_uom = frappe.get_cached_value("Item", item_code, "stock_uom") if item_code else None
|
||||
row.item_image = frappe.get_cached_value("Item", item_code, "image") if item_code else None
|
||||
row.status_colour = get_status_color(row.status)
|
||||
row.time_logs = time_logs.get(row.name, [])
|
||||
row.make_material_request = bool(row.for_quantity > row.transferred_qty or allow_excess_transfer)
|
||||
# Required vs transferred + on-hand in source — operator sees shortages before starting work.
|
||||
row.materials = get_job_card_materials(row.name)
|
||||
# Guided execution: per-operation work instructions + quality-check state for the card.
|
||||
row.instructions = _get_operation_instructions(row.operation)
|
||||
row.qc = _get_job_card_qc(row)
|
||||
|
||||
|
||||
def _get_operation_instructions(operation: str | None) -> dict | None:
|
||||
"""Description + rich Work Instructions from the Operation master, for the card's
|
||||
Instructions panel. Returns None when the operation has neither, so the panel stays hidden.
|
||||
|
||||
`work_instruction` is a Text Editor field (HTML) — Frappe bleach-sanitizes it on save, so it
|
||||
is safe to render as-is on the client. `description` is plain text and must be escaped there.
|
||||
"""
|
||||
if not operation:
|
||||
return None
|
||||
|
||||
op = frappe.get_cached_value("Operation", operation, ["description", "work_instruction"], as_dict=True)
|
||||
if not op:
|
||||
return None
|
||||
|
||||
description = (op.description or "").strip()
|
||||
work_instruction = (op.work_instruction or "").strip()
|
||||
if not description and not work_instruction:
|
||||
return None
|
||||
return {"description": description, "work_instruction": work_instruction}
|
||||
|
||||
|
||||
def _get_job_card_qc(row) -> dict:
|
||||
"""Quality-check state for a job card row: whether an inspection is required before submit,
|
||||
which template to use, and any inspection already linked (name + status + docstatus).
|
||||
|
||||
"Required" mirrors Job Card.validate_inspection() — BOM inspection_required AND the Work Order
|
||||
Operation's quality_inspection_required. When only a template is configured the check is
|
||||
offered but not enforced.
|
||||
"""
|
||||
required = bool(
|
||||
row.get("bom_no")
|
||||
and frappe.get_cached_value("BOM", row.bom_no, "inspection_required")
|
||||
and row.get("operation_id")
|
||||
and frappe.db.get_value("Work Order Operation", row.operation_id, "quality_inspection_required")
|
||||
)
|
||||
|
||||
template = row.get("quality_inspection_template")
|
||||
if not template and row.get("operation"):
|
||||
template = frappe.get_cached_value("Operation", row.operation, "quality_inspection_template")
|
||||
|
||||
info = {
|
||||
"required": required,
|
||||
"template": template,
|
||||
"has_checklist": bool(template),
|
||||
"name": None,
|
||||
"status": None,
|
||||
"docstatus": None,
|
||||
}
|
||||
if row.get("quality_inspection"):
|
||||
qi = frappe.db.get_value(
|
||||
"Quality Inspection", row.quality_inspection, ["name", "status", "docstatus"], as_dict=True
|
||||
)
|
||||
if qi:
|
||||
info.update({"name": qi.name, "status": qi.status, "docstatus": qi.docstatus})
|
||||
return info
|
||||
|
||||
|
||||
def get_job_card_materials(job_card: str) -> list[dict]:
|
||||
"""Required vs transferred + on-hand stock for each raw material in the source warehouse.
|
||||
|
||||
Powers the active-job Materials side panel — operator sees shortages before starting work.
|
||||
"""
|
||||
items = frappe.get_all(
|
||||
"Job Card Item",
|
||||
filters={"parent": job_card},
|
||||
fields=["item_code", "item_name", "source_warehouse", "required_qty", "transferred_qty", "uom"],
|
||||
order_by="idx",
|
||||
)
|
||||
if not items:
|
||||
return []
|
||||
|
||||
on_hand_map = _get_on_hand_map(items)
|
||||
return [_build_material_row(it, on_hand_map) for it in items]
|
||||
|
||||
|
||||
def _get_on_hand_map(items) -> dict[tuple[str, str], float]:
|
||||
"""Map (item_code, warehouse) → on-hand qty via batched Bin lookups."""
|
||||
pairs = {(it.item_code, it.source_warehouse) for it in items if it.source_warehouse}
|
||||
if not pairs:
|
||||
return {}
|
||||
|
||||
bin_rows = frappe.get_all(
|
||||
"Bin",
|
||||
filters={
|
||||
"item_code": ["in", list({p[0] for p in pairs})],
|
||||
"warehouse": ["in", list({p[1] for p in pairs})],
|
||||
},
|
||||
fields=["item_code", "warehouse", "actual_qty"],
|
||||
)
|
||||
return {(b.item_code, b.warehouse): flt(b.actual_qty) for b in bin_rows}
|
||||
|
||||
|
||||
def _build_material_row(it, on_hand_map) -> dict:
|
||||
"""One material entry with shortage + status pill for the side panel."""
|
||||
required = flt(it.required_qty)
|
||||
transferred = flt(it.transferred_qty)
|
||||
on_hand = on_hand_map.get((it.item_code, it.source_warehouse), 0.0)
|
||||
shortage = max(required - transferred, 0.0)
|
||||
if transferred >= required:
|
||||
status = "ready"
|
||||
elif on_hand >= shortage:
|
||||
status = "available"
|
||||
else:
|
||||
status = "short"
|
||||
return {
|
||||
"item_code": it.item_code,
|
||||
"item_name": it.item_name or it.item_code,
|
||||
"source_warehouse": it.source_warehouse,
|
||||
"required_qty": required,
|
||||
"transferred_qty": transferred,
|
||||
"on_hand_qty": on_hand,
|
||||
"shortage": shortage,
|
||||
"uom": it.uom or "",
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def get_today_sessions(workstation: str | None, work_order: str | None) -> list[dict]:
|
||||
"""Submitted job cards finalized today — used for the bottom 'Today's Sessions' strip.
|
||||
|
||||
Filtered on docstatus=1 only (draft/cancelled excluded). The status pill follows the
|
||||
job card's own status (e.g. Work In Progress → orange, Completed → green).
|
||||
"""
|
||||
filters = _today_sessions_filters(workstation, work_order)
|
||||
if filters is None:
|
||||
return []
|
||||
|
||||
rows = frappe.get_all(
|
||||
"Job Card",
|
||||
filters=filters,
|
||||
fields=TODAY_SESSION_FIELDS,
|
||||
order_by="modified desc",
|
||||
limit=10,
|
||||
)
|
||||
for r in rows:
|
||||
item_code = r.finished_good or r.production_item
|
||||
r.item_image = frappe.get_cached_value("Item", item_code, "image") if item_code else None
|
||||
r.status_colour = get_status_color(r.status)
|
||||
return rows
|
||||
|
||||
|
||||
def _today_sessions_filters(workstation, work_order) -> dict | None:
|
||||
"""Submitted-today filter scoped to a work order or workstation; None if neither given.
|
||||
|
||||
"To Manufacture" cards are excluded — they aren't finalized yet (Manufacture Stock Entry
|
||||
pending) and get their own section, so they shouldn't appear among finished sessions.
|
||||
"""
|
||||
filters = {
|
||||
"docstatus": 1,
|
||||
"modified": [">=", get_datetime(f"{getdate()} 00:00:00")],
|
||||
"status": ["!=", "To Manufacture"],
|
||||
}
|
||||
if work_order:
|
||||
filters["work_order"] = work_order
|
||||
elif workstation:
|
||||
filters["workstation"] = workstation
|
||||
else:
|
||||
return None
|
||||
return filters
|
||||
|
||||
|
||||
def get_workstation_oee(workstation: str) -> dict | None:
|
||||
"""
|
||||
OEE = Availability X Performance X Quality, computed for today only.
|
||||
|
||||
Caveat: without a downtime-reason capture step, Availability is just
|
||||
(actual_run_time / scheduled_time) — it cannot distinguish planned breaks
|
||||
from unplanned breakdowns. The number is directional, not audit-grade.
|
||||
"""
|
||||
today = getdate()
|
||||
scheduled_min = flt(frappe.db.get_value("Workstation", workstation, "total_working_hours")) * 60
|
||||
actual_run_min, ideal_min = _get_run_and_ideal_minutes(workstation, today)
|
||||
completed_jcs = _get_completed_jcs_today(workstation, today)
|
||||
|
||||
# No activity at all today — nothing to display.
|
||||
if actual_run_min == 0 and not completed_jcs:
|
||||
return None
|
||||
return _build_oee(scheduled_min, actual_run_min, ideal_min, completed_jcs)
|
||||
|
||||
|
||||
def _get_run_and_ideal_minutes(workstation, today) -> tuple[float, float]:
|
||||
"""Sum actual run minutes (clipped to today) and the ideal minutes for produced qty."""
|
||||
today_start = get_datetime(f"{today} 00:00:00")
|
||||
today_end = get_datetime(f"{today} 23:59:59")
|
||||
now = now_datetime()
|
||||
actual_run_min = 0.0
|
||||
ideal_min = 0.0
|
||||
for log in _get_oee_time_logs(workstation, today_start, today_end):
|
||||
# Clip the log's interval to today's window for fair attribution.
|
||||
start = max(get_datetime(log.from_time), today_start)
|
||||
end = min(get_datetime(log.to_time) if log.to_time else now, today_end)
|
||||
if end > start:
|
||||
actual_run_min += time_diff_in_seconds(end, start) / 60
|
||||
if log.completed_qty and log.for_quantity and log.time_required:
|
||||
ideal_min += (flt(log.time_required) / flt(log.for_quantity)) * flt(log.completed_qty)
|
||||
return actual_run_min, ideal_min
|
||||
|
||||
|
||||
def _get_oee_time_logs(workstation, today_start, today_end) -> list[dict]:
|
||||
"""Job Card time logs whose interval overlaps today's window."""
|
||||
tl = frappe.qb.DocType("Job Card Time Log")
|
||||
jc = frappe.qb.DocType("Job Card")
|
||||
return (
|
||||
frappe.qb.from_(tl)
|
||||
.inner_join(jc)
|
||||
.on(jc.name == tl.parent)
|
||||
.select(tl.from_time, tl.to_time, tl.completed_qty, jc.for_quantity, jc.time_required)
|
||||
.where(jc.workstation == workstation)
|
||||
.where(jc.docstatus < 2)
|
||||
.where(tl.from_time <= today_end)
|
||||
.where(tl.to_time.isnull() | (tl.to_time >= today_start))
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def _get_completed_jcs_today(workstation, today) -> list[dict]:
|
||||
"""Job cards completed/submitted today — process loss is finalized at submission."""
|
||||
jc = frappe.qb.DocType("Job Card")
|
||||
return (
|
||||
frappe.qb.from_(jc)
|
||||
.select(jc.total_completed_qty, jc.process_loss_qty)
|
||||
.where(jc.workstation == workstation)
|
||||
.where(jc.status.isin(["Completed", "Submitted"]))
|
||||
.where(Date(jc.modified) == today)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def _build_oee(scheduled_min, actual_run_min, ideal_min, completed_jcs) -> dict:
|
||||
"""Combine the three OEE factors into the response payload."""
|
||||
total_completed = sum(flt(j.total_completed_qty) for j in completed_jcs)
|
||||
total_loss = sum(flt(j.process_loss_qty) for j in completed_jcs)
|
||||
availability = min(actual_run_min / scheduled_min, 1.0) if scheduled_min > 0 else None
|
||||
performance = min(ideal_min / actual_run_min, 1.0) if actual_run_min > 0 else 0.0
|
||||
quality = max(total_completed - total_loss, 0.0) / total_completed if total_completed > 0 else 1.0
|
||||
# OEE requires all three factors; without a schedule, Availability is unknown.
|
||||
oee_val = round(availability * performance * quality * 100, 1) if availability is not None else None
|
||||
return {
|
||||
"oee": oee_val,
|
||||
"availability": round(availability * 100, 1) if availability is not None else None,
|
||||
"performance": round(performance * 100, 1),
|
||||
"quality": round(quality * 100, 1),
|
||||
}
|
||||
@@ -496,8 +496,5 @@ erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||
erpnext.patches.v16_0.fix_subcontracting_titles
|
||||
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
<<<<<<< HEAD
|
||||
erpnext.patches.v16_0.rename_italy_customer_name_fields
|
||||
=======
|
||||
erpnext.patches.v16_0.set_stock_uom_in_job_card
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -296,6 +296,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
self.validate_batch()
|
||||
self.validate_inspection()
|
||||
self.validate_fg_completed_qty()
|
||||
self.validate_job_card_pending_production()
|
||||
self.validate_difference_account()
|
||||
self.set_job_card_data()
|
||||
self.validate_job_card_item()
|
||||
@@ -3192,21 +3193,11 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
return
|
||||
|
||||
precision = self.precision("process_loss_qty")
|
||||
if self.work_order:
|
||||
data = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
filters={"parent": self.work_order},
|
||||
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
|
||||
)
|
||||
process_loss_qty = self.get_pending_process_loss_qty()
|
||||
if process_loss_qty and flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision):
|
||||
self.process_loss_qty = flt(process_loss_qty, precision)
|
||||
|
||||
if data and data[0].process_loss_qty:
|
||||
process_loss_qty = data[0].process_loss_qty
|
||||
if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision):
|
||||
self.process_loss_qty = flt(process_loss_qty, precision)
|
||||
|
||||
frappe.msgprint(
|
||||
_("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True
|
||||
)
|
||||
frappe.msgprint(_("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True)
|
||||
|
||||
if not self.process_loss_percentage and not self.process_loss_qty:
|
||||
self.process_loss_percentage = frappe.get_cached_value(
|
||||
@@ -3222,6 +3213,62 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
(flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100
|
||||
)
|
||||
|
||||
def validate_job_card_pending_production(self):
|
||||
"""A draft created before other entries were submitted must not book more than the job
|
||||
card still has left; without this, a stale draft over-produces the finished good."""
|
||||
if self.purpose != "Manufacture" or not self.job_card:
|
||||
return
|
||||
|
||||
if self._action == "update_after_submit":
|
||||
return
|
||||
|
||||
job_card = frappe.get_doc("Job Card", self.job_card)
|
||||
if job_card.is_corrective_job_card or job_card.is_subcontracted:
|
||||
return
|
||||
|
||||
precision = frappe.get_precision("Stock Entry Detail", "qty")
|
||||
pending_qty = flt(
|
||||
flt(job_card.for_quantity)
|
||||
- flt(job_card.pending_qty)
|
||||
- flt(job_card.manufactured_qty)
|
||||
- flt(job_card.get_consumed_process_loss()),
|
||||
precision,
|
||||
)
|
||||
finished_qty = flt(sum(flt(d.transfer_qty) for d in self.items if d.is_finished_item), precision)
|
||||
entry_qty = flt(finished_qty + flt(self.process_loss_qty), precision)
|
||||
|
||||
if entry_qty > pending_qty:
|
||||
item_code = job_card.finished_good or job_card.production_item
|
||||
uom = frappe.get_cached_value("Item", item_code, "stock_uom")
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Job Card {0} has only {1} left to produce, but this entry books {2} ({3} finished goods and {4} process loss). Cancel or update its other manufacture entries first."
|
||||
).format(
|
||||
frappe.bold(self.job_card),
|
||||
frappe.bold(f"{pending_qty} {uom}"),
|
||||
frappe.bold(f"{entry_qty} {uom}"),
|
||||
f"{finished_qty} {uom}",
|
||||
f"{flt(self.process_loss_qty, precision)} {uom}",
|
||||
)
|
||||
)
|
||||
|
||||
def get_pending_process_loss_qty(self):
|
||||
"""Loss this entry should still book: the job card's unbooked loss when the entry
|
||||
belongs to one, else the largest operation loss on the work order (legacy flow)."""
|
||||
if self.job_card:
|
||||
job_card = frappe.get_doc("Job Card", self.job_card)
|
||||
return max(flt(job_card.process_loss_qty) - flt(job_card.get_consumed_process_loss()), 0)
|
||||
|
||||
if self.work_order:
|
||||
data = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
filters={"parent": self.work_order},
|
||||
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
|
||||
)
|
||||
return flt(data[0].process_loss_qty) if data else 0
|
||||
|
||||
return 0
|
||||
|
||||
def set_work_order_details(self):
|
||||
if not getattr(self, "pro_doc", None):
|
||||
self.pro_doc = frappe._dict()
|
||||
|
||||
@@ -125,6 +125,8 @@ class ManufactureEntry:
|
||||
if backflush_based_on != "BOM":
|
||||
available_serial_batches = self.get_transferred_serial_batches()
|
||||
|
||||
production_share = self.get_production_share()
|
||||
items_to_remove = []
|
||||
for item_code, _dict in item_dict.items():
|
||||
_dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
|
||||
_dict.to_warehouse = ""
|
||||
@@ -138,11 +140,33 @@ class ManufactureEntry:
|
||||
|
||||
_dict.qty = calculated_qty
|
||||
self.update_available_serial_batches(_dict, available_serial_batches)
|
||||
elif self.skip_material_transfer:
|
||||
set_previous_operation_serial_batch(self.stock_entry, _dict)
|
||||
else:
|
||||
remaining_qty = max(flt(_dict.qty) - flt(_dict.consumed_qty), 0)
|
||||
_dict.qty = min(flt(_dict.qty) * production_share, remaining_qty)
|
||||
if not _dict.qty:
|
||||
items_to_remove.append(item_code)
|
||||
continue
|
||||
|
||||
if self.skip_material_transfer:
|
||||
set_previous_operation_serial_batch(self.stock_entry, _dict)
|
||||
|
||||
for item_code in items_to_remove:
|
||||
item_dict.pop(item_code)
|
||||
|
||||
self.stock_entry.add_to_stock_entry_detail(item_dict)
|
||||
|
||||
def get_production_share(self):
|
||||
"""Fraction of the job card's production this entry accounts for; raw materials are
|
||||
generated proportionally so several partial entries never consume more than required."""
|
||||
for_quantity, pending_qty = frappe.db.get_value(
|
||||
"Job Card", self.job_card, ["for_quantity", "pending_qty"]
|
||||
)
|
||||
qty_to_produce = flt(for_quantity) - flt(pending_qty)
|
||||
if not qty_to_produce:
|
||||
return 1
|
||||
|
||||
return min(flt(self.for_quantity) / qty_to_produce, 1)
|
||||
|
||||
def parse_available_serial_batches(self, item_dict, available_serial_batches):
|
||||
key = (item_dict.item_code, item_dict.from_warehouse)
|
||||
if key not in available_serial_batches:
|
||||
|
||||
Reference in New Issue
Block a user