mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-11 13:41:47 +00:00
feat(job_card): print quantities with their stock uom (#57689)
* feat(job_card): carry the stock uom on the job card
Every quantity the job card reports belongs to the item it produces, but the
document had no unit of its own, so messages could only print bare numbers.
Add the Stock UOM field, set from the finished good or the final product, and
backfill the job cards that already exist.
* fix(job_card): print quantities with their unit
A bare 5 in an error says nothing about what was counted. Every message that
reports a quantity now names its unit, taking it from the job card's stock uom,
from the previous operation's finished good when the message compares two
operations, and from the item itself for a raw material transfer.
The completion dialogs read the same unit off the job card.
* refactor(job_card): move the stock uom next to the qty it measures
* fix(job_card): keep the stock uom backfill atomic
Drop the auto commit toggle so the backfill is a single transaction with no
connection flag left behind when it raises, and select the job cards to fill
with an explicit unset filter instead of a value list.
(cherry picked from commit 07ac4d83ef)
# Conflicts:
# erpnext/manufacturing/doctype/job_card/job_card.js
# erpnext/manufacturing/doctype/job_card/job_card.json
# erpnext/manufacturing/doctype/job_card/job_card.py
# erpnext/manufacturing/doctype/job_card/test_job_card.py
# erpnext/manufacturing/page/shop_floor/shop_floor.py
# erpnext/patches.txt
# erpnext/public/js/shop_floor/shop_floor.js
This commit is contained in:
@@ -67,7 +67,11 @@ frappe.ui.form.on("Job Card", {
|
||||
if (remaining_qty < frm.doc.pending_qty) {
|
||||
frm.doc.pending_qty = 0.0;
|
||||
refresh_field("pending_qty");
|
||||
frappe.throw(__("Pending Quantity cannot be greater than {0}", [remaining_qty]));
|
||||
frappe.throw(
|
||||
__("Pending Quantity cannot be greater than {0}", [
|
||||
get_qty_with_uom(remaining_qty, frm.doc.stock_uom),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
const process_loss_qty = flt(remaining_qty) - flt(frm.doc.pending_qty);
|
||||
@@ -261,8 +265,28 @@ 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") -
|
||||
dialog.get_value("process_loss_qty");
|
||||
|
||||
if (remaining < 0) {
|
||||
const max_completed_qty =
|
||||
flt(dialog.get_value("for_quantity")) - flt(dialog.get_value("process_loss_qty"));
|
||||
dialog.set_value("completed_qty", max_completed_qty);
|
||||
frappe.throw(
|
||||
__("Completed Quantity cannot be greater than {0}", [
|
||||
get_qty_with_uom(max_completed_qty, frm.doc.stock_uom),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (remaining != dialog.get_value("pending_qty")) {
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
dialog.set_value("pending_qty", remaining);
|
||||
}
|
||||
},
|
||||
@@ -278,7 +302,25 @@ frappe.ui.form.on("Job Card", {
|
||||
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);
|
||||
frappe.throw(
|
||||
__("Pending Quantity cannot be greater than {0}", [
|
||||
get_qty_with_uom(
|
||||
flt(dialog.get_value("for_quantity")) -
|
||||
flt(dialog.get_value("completed_qty")),
|
||||
frm.doc.stock_uom
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
@@ -293,7 +335,25 @@ frappe.ui.form.on("Job Card", {
|
||||
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);
|
||||
frappe.throw(
|
||||
__("Process Loss Quantity cannot be greater than {0}", [
|
||||
get_qty_with_uom(
|
||||
flt(dialog.get_value("for_quantity")) -
|
||||
flt(dialog.get_value("completed_qty")),
|
||||
frm.doc.stock_uom
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (remaining != dialog.get_value("pending_qty")) {
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
dialog.set_value("pending_qty", remaining);
|
||||
}
|
||||
},
|
||||
@@ -886,3 +946,7 @@ function get_last_completed_row(time_logs) {
|
||||
function get_last_row(time_logs) {
|
||||
return time_logs[time_logs.length - 1] || {};
|
||||
}
|
||||
|
||||
function get_qty_with_uom(qty, stock_uom) {
|
||||
return stock_uom ? `${flt(qty)} ${stock_uom}` : flt(qty);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
"work_order",
|
||||
"column_break_uqjq",
|
||||
"production_item",
|
||||
"bom_no",
|
||||
"column_break_qrpg",
|
||||
"for_quantity",
|
||||
"column_break_yecz",
|
||||
"bom_no",
|
||||
"stock_uom",
|
||||
"section_break_oisd",
|
||||
"company",
|
||||
"naming_series",
|
||||
@@ -164,6 +165,13 @@
|
||||
"in_preview": 1,
|
||||
"label": "Qty To Manufacture"
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"label": "Stock UOM",
|
||||
"options": "UOM",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "wip_warehouse",
|
||||
"fieldtype": "Link",
|
||||
@@ -695,7 +703,11 @@
|
||||
"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",
|
||||
|
||||
@@ -130,6 +130,7 @@ class JobCard(Document):
|
||||
"Cancelled",
|
||||
"Completed",
|
||||
]
|
||||
stock_uom: DF.Link | None
|
||||
sub_operations: DF.Table[JobCardOperation]
|
||||
target_warehouse: DF.Link | None
|
||||
time_logs: DF.Table[JobCardTimeLog]
|
||||
@@ -158,6 +159,7 @@ class JobCard(Document):
|
||||
|
||||
def before_validate(self):
|
||||
self.set_wip_warehouse()
|
||||
self.set_stock_uom()
|
||||
|
||||
def validate(self):
|
||||
self.validate_time_logs()
|
||||
@@ -906,11 +908,21 @@ class JobCard(Document):
|
||||
qty_to_manufacture = bold(_("Qty to Manufacture"))
|
||||
|
||||
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(
|
||||
bold(self.get_qty_with_uom(self.total_completed_qty)),
|
||||
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))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1184,6 +1196,51 @@ 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
|
||||
|
||||
@@ -1280,10 +1337,66 @@ 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)
|
||||
|
||||
def get_qty_with_uom(self, qty, item_code=None):
|
||||
"""A quantity in a message reads as a count of nothing without the unit it is measured in."""
|
||||
uom = self.stock_uom
|
||||
if item_code:
|
||||
uom = frappe.get_cached_value("Item", item_code, "stock_uom")
|
||||
|
||||
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 validate_operation_id(self):
|
||||
if (
|
||||
self.get("operation_id")
|
||||
@@ -1294,6 +1407,36 @@ 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
|
||||
@@ -1339,6 +1482,53 @@ class JobCard(Document):
|
||||
if not (self.work_order and self.sequence_id):
|
||||
return
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
current_operation_qty = self.get_current_operation_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)
|
||||
|
||||
def get_previous_operations(self):
|
||||
previous_operations = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"],
|
||||
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
|
||||
order_by="sequence_id, idx",
|
||||
)
|
||||
|
||||
if self.track_semi_finished_goods and previous_operations:
|
||||
manufactured_qty = self.get_manufactured_qty_per_operation(
|
||||
[row.name for row in previous_operations]
|
||||
)
|
||||
|
||||
for row in previous_operations:
|
||||
row.manufactured_qty = flt(manufactured_qty.get(row.name))
|
||||
|
||||
return previous_operations
|
||||
|
||||
def get_manufactured_qty_per_operation(self, operation_ids):
|
||||
job_card = frappe.qb.DocType("Job Card")
|
||||
|
||||
data = (
|
||||
frappe.qb.from_(job_card)
|
||||
.select(job_card.operation_id, Sum(job_card.manufactured_qty))
|
||||
.where(
|
||||
(job_card.work_order == self.work_order)
|
||||
& (job_card.docstatus == 1)
|
||||
& (IfNull(job_card.is_corrective_job_card, 0) == 0)
|
||||
& (job_card.operation_id.isin(operation_ids))
|
||||
)
|
||||
.groupby(job_card.operation_id)
|
||||
).run()
|
||||
|
||||
return dict(data)
|
||||
|
||||
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:
|
||||
@@ -1353,6 +1543,7 @@ class JobCard(Document):
|
||||
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))
|
||||
)
|
||||
@@ -1364,6 +1555,17 @@ class JobCard(Document):
|
||||
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:
|
||||
@@ -1374,6 +1576,7 @@ class JobCard(Document):
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
<<<<<<< HEAD
|
||||
if row.completed_qty < current_operation_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
@@ -1385,6 +1588,33 @@ class JobCard(Document):
|
||||
bold(row.operation),
|
||||
)
|
||||
)
|
||||
=======
|
||||
if not manufactured_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Job Card {0}: As per the sequence of the operations in the work order {1}, submit the manufacturing entry for the operation {2} before the operation {3}."
|
||||
).format(
|
||||
bold(self.name),
|
||||
bold(get_link_to_form("Work Order", self.work_order)),
|
||||
bold(row.operation),
|
||||
bold(self.operation),
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
if manufactured_qty < current_operation_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(current_operation_qty)),
|
||||
bold(self.operation),
|
||||
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
|
||||
bold(row.operation),
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
>>>>>>> 07ac4d83ef (feat(job_card): print quantities with their stock uom (#57689))
|
||||
|
||||
def validate_work_order(self):
|
||||
if self.is_work_order_closed():
|
||||
@@ -1536,6 +1766,31 @@ class JobCard(Document):
|
||||
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,
|
||||
|
||||
@@ -888,6 +888,85 @@ 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",
|
||||
frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order},
|
||||
order_by="sequence_id, creation",
|
||||
limit=1,
|
||||
pluck="name",
|
||||
)[0],
|
||||
)
|
||||
|
||||
def test_stock_uom_is_set_from_the_produced_item(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
job_card = self.get_first_job_card(work_order.name)
|
||||
item_code = job_card.finished_good or job_card.production_item
|
||||
|
||||
self.assertEqual(job_card.stock_uom, frappe.db.get_value("Item", item_code, "stock_uom"))
|
||||
|
||||
def test_completion_qty_reduces_for_quantity_without_process_loss(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
job_card = self.get_first_job_card(work_order.name)
|
||||
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=3,
|
||||
for_quantity=3,
|
||||
pending_qty=0,
|
||||
process_loss_qty=0,
|
||||
end_time="2024-03-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.for_quantity), 3)
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 3)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 0)
|
||||
|
||||
def test_completion_qty_keeps_for_quantity_across_cycles(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
job_card = self.get_first_job_card(work_order.name)
|
||||
job_card.append("time_logs", {"from_time": "2024-03-02 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-03-02 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.append("time_logs", {"from_time": "2024-03-02 10:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=2,
|
||||
for_quantity=2,
|
||||
pending_qty=0,
|
||||
process_loss_qty=0,
|
||||
end_time="2024-03-02 11:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.for_quantity), 5)
|
||||
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,
|
||||
@@ -1879,3 +1958,101 @@ 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))
|
||||
|
||||
834
erpnext/manufacturing/page/shop_floor/shop_floor.py
Normal file
834
erpnext/manufacturing/page/shop_floor/shop_floor.py
Normal file
@@ -0,0 +1,834 @@
|
||||
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,4 +496,8 @@ 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))
|
||||
|
||||
36
erpnext/patches/v16_0/set_stock_uom_in_job_card.py
Normal file
36
erpnext/patches/v16_0/set_stock_uom_in_job_card.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"stock_uom": ("is", "not set")},
|
||||
fields=["name", "finished_good", "production_item"],
|
||||
)
|
||||
|
||||
if not job_cards:
|
||||
return
|
||||
|
||||
item_codes = {code for row in job_cards if (code := row.finished_good or row.production_item)}
|
||||
if not item_codes:
|
||||
return
|
||||
|
||||
stock_uoms = dict(
|
||||
frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("in", list(item_codes))},
|
||||
fields=["name", "stock_uom"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
updates = {}
|
||||
for row in job_cards:
|
||||
stock_uom = stock_uoms.get(row.finished_good or row.production_item)
|
||||
if stock_uom:
|
||||
updates[row.name] = {"stock_uom": stock_uom}
|
||||
|
||||
if not updates:
|
||||
return
|
||||
|
||||
frappe.db.bulk_update("Job Card", updates)
|
||||
1758
erpnext/public/js/shop_floor/shop_floor.js
Normal file
1758
erpnext/public/js/shop_floor/shop_floor.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user