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:
Mihir Kandoi
2026-08-01 18:34:31 +05:30
committed by Mergify
parent 7d5c58b8d3
commit eb90186d3f
8 changed files with 3142 additions and 2 deletions

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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))

View 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),
}

View File

@@ -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))

View 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)

File diff suppressed because it is too large Load Diff