Compare commits

...

14 Commits

Author SHA1 Message Date
Mihir Kandoi
b5cb12c3d7 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.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
500b9073e7 refactor(job_card): move the stock uom next to the qty it measures 2026-08-01 15:09:51 +05:30
Mihir Kandoi
d4d5bf5d86 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.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
a8f189696e 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.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
02c066a634 refactor(job_card): make the completion dialog say what it asks for
The dialog qty shares the Qty to Manufacture label with the field on the form
while it means the current cycle only, its title fell back to the generic Enter
Value because frappe.prompt takes four arguments and it was passed five, and
nothing on it stated that the three quantities have to add up.

Name the cycle in the label, title the dialog after the button that opens it,
and describe the split on the fields. Same wording in the shop floor dialog.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
f48538aeae refactor(job_card): drop the unused make_finished_good handler
Nothing triggered it and Job Card has no make_finished_good method to call.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
2c131d5819 test(job_card): cover the completion qty split guard 2026-08-01 15:09:51 +05:30
Mihir Kandoi
e042d09975 fix(job_card): reject a completion split that cannot add up
The completion dialogs silently dropped a recalculation whose result went
negative, so entering a pending qty larger than what is left of the qty to
manufacture kept the contradiction (3 to manufacture, 3 completed, 2 pending)
and the job card only failed much later, on submission.

Keep the split consistent while it is entered: reset the pending qty when the
qty to manufacture changes, and refuse a completed, pending or process loss qty
that leaves the others negative. complete_job_card validates the same rule, so
the shop floor and the API cannot store a split that will never submit.

Also name the three parts in the submission error instead of calling their sum
the Total Completed Qty, which read as a contradiction of the field itself.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
2565a56ade test(job_card): cover a job card completed with a pending qty 2026-08-01 15:09:51 +05:30
Mihir Kandoi
3fab303e51 fix(job_card): leave the pending qty out of the job card's own output
Pending qty is the part of a job card handed over to another job card, but the
status and the manufacturing entry still measured the card against its full
for_quantity. A card submitted with 3 completed and 2 pending was stuck at Work
In Progress with no way to change it, and its manufacturing entry was built for
the full 5.

Measure both against for_quantity minus pending qty, so the card reaches To
Manufacture on submission, its manufacturing entry covers the completed qty, and
it is Completed once that qty is manufactured.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
6ef498e352 test(job_card): cover qty to manufacture from the completion dialog
Reducing the dialog qty resizes the job card without inventing process loss, and
a pending qty split across two cycles leaves for_quantity untouched.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
9815d90b0f fix(job_card): apply the completion dialog's qty to manufacture
Both the desk dialog and the shop floor session dialog send for_quantity when
completing a job card, but complete_job_card dropped it. Reducing Qty to
Manufacture to 3 on a job card of 5 left for_quantity at 5, so set_process_loss
turned the untouched 2 into process loss on the next save.

The dialog qty covers the current cycle, so add it to the qty already completed
by the earlier cycles of the job card instead of overwriting for_quantity, and
validate the pending qty against the result.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
fb763848da test(job_card): cover manufactured qty check across previous operations
Work order with operations A and B at sequence 1 and C at sequence 2, tracking
semi finished goods. C stays blocked while A's job card is submitted but its
Manufacture entry is missing, and once A is manufactured for 3, C can only be
completed for 3.
2026-08-01 15:09:51 +05:30
Mihir Kandoi
c2654c1380 fix(job_card): block next operation until previous operation is manufactured
With track semi finished goods, Work Order Operation completed_qty is set from
the submitted job cards' total completed qty, so a job card of the next
operation could be started and completed even when no Manufacture entry existed
for the previous operation. The semi-finished goods it consumes were never
produced.

Validate the sequence against the qty actually manufactured against the previous
operations' job cards (Manufacture entries / Subcontracting Receipts) when the
work order tracks semi finished goods.
2026-08-01 15:09:51 +05:30
8 changed files with 618 additions and 74 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);
@@ -99,7 +103,8 @@ frappe.ui.form.on("Job Card", {
doc.docstatus === 1 &&
!doc.is_subcontracted &&
(doc.skip_material_transfer || doc.transferred_qty > 0) &&
flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < flt(doc.for_quantity);
flt(doc.manufactured_qty) + flt(doc.process_loss_qty) <
flt(doc.for_quantity) - flt(doc.pending_qty);
if (!can_make_stock_entry) return;
@@ -241,13 +246,15 @@ frappe.ui.form.on("Job Card", {
const fields = [
{
fieldtype: "Float",
label: __("Qty to Manufacture"),
label: __("Qty to Manufacture in this Cycle"),
fieldname: "for_quantity",
reqd: 1,
default: pending_qty,
description: __("Completed, Pending and Process Loss quantities must add up to this."),
change() {
const dialog = frm.job_completion_dialog;
dialog.set_value("completed_qty", dialog.get_value("for_quantity"));
dialog.set_value("pending_qty", 0);
dialog.set_value("process_loss_qty", 0);
},
},
@@ -259,8 +266,23 @@ frappe.ui.form.on("Job Card", {
default: pending_qty,
change() {
const dialog = frm.job_completion_dialog;
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")) {
dialog.set_value("pending_qty", remaining);
}
},
@@ -270,13 +292,28 @@ frappe.ui.form.on("Job Card", {
label: __("Pending Quantity"),
fieldname: "pending_qty",
default: 0.0,
description: __("Qty left for a later cycle or for another job card."),
change() {
const dialog = frm.job_completion_dialog;
const process_loss_qty =
dialog.get_value("for_quantity") -
dialog.get_value("completed_qty") -
dialog.get_value("pending_qty");
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")) {
dialog.set_value("process_loss_qty", process_loss_qty);
}
},
@@ -285,13 +322,28 @@ frappe.ui.form.on("Job Card", {
fieldtype: "Float",
label: __("Process Loss Quantity"),
fieldname: "process_loss_qty",
description: __("Qty scrapped in this cycle, nobody will produce it."),
onchange() {
const dialog = frm.job_completion_dialog;
const remaining =
dialog.get_value("for_quantity") -
dialog.get_value("completed_qty") -
dialog.get_value("process_loss_qty");
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")) {
dialog.set_value("pending_qty", remaining);
}
},
@@ -355,9 +407,8 @@ frappe.ui.form.on("Job Card", {
},
});
},
__("Enter Value"),
__("Update"),
__("Set Finished Good Quantity")
__("Complete Job"),
__("Update")
);
},
@@ -383,46 +434,6 @@ frappe.ui.form.on("Job Card", {
});
},
make_finished_good(frm) {
const fields = [
{
fieldtype: "Float",
label: __("Completed Quantity"),
fieldname: "qty",
reqd: 1,
default: frm.doc.for_quantity - frm.doc.manufactured_qty,
},
{
fieldtype: "Datetime",
label: __("End Time"),
fieldname: "end_time",
default: frappe.datetime.now_datetime(),
},
];
frappe.prompt(
fields,
(data) => {
if (data.qty <= 0) {
frappe.throw(__("Quantity should be greater than 0"));
}
frm.call({
method: "make_finished_good",
doc: frm.doc,
args: { qty: data.qty, end_time: data.end_time },
callback(r) {
const doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
},
__("Enter Value"),
__("Update"),
__("Set Finished Good Quantity")
);
},
setup_quality_inspection(frm) {
const quality_inspection_field = frm.get_docfield("quality_inspection");
quality_inspection_field.get_route_options_for_new_doc = function (frm) {
@@ -885,3 +896,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",
@@ -163,6 +164,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",
@@ -689,7 +697,7 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-07-23 12:00:00.000000",
"modified": "2026-08-01 14:22:19.926911",
"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()
@@ -944,19 +946,21 @@ class JobCard(Document):
return
precision = self.precision("total_completed_qty")
total_completed_qty = flt(
accounted_qty = flt(
flt(self.total_completed_qty, precision)
+ flt(self.process_loss_qty, precision)
+ flt(self.pending_qty, precision)
)
if self.for_quantity and flt(total_completed_qty, precision) != flt(self.for_quantity, precision):
if self.for_quantity and flt(accounted_qty, precision) != flt(self.for_quantity, precision):
frappe.throw(
_("The {0} ({1}) must be equal to {2} ({3})").format(
bold(_("Total Completed Qty")),
bold(flt(total_completed_qty, precision)),
bold(_("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)),
)
)
@@ -1239,7 +1243,12 @@ class JobCard(Document):
frappe.throw(
_(
"Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
).format(row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card),
).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,
)
@@ -1310,11 +1319,25 @@ class JobCard(Document):
if self.workstation:
self.update_workstation_status()
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().
if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
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) >= self.for_quantity:
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"
@@ -1344,7 +1367,7 @@ class JobCard(Document):
self.status = "Work In Progress"
if self.docstatus == 1 and (
self.for_quantity <= (self.total_completed_qty + self.process_loss_qty) or not self.items
self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty) or not self.items
):
self.status = "Completed"
@@ -1352,6 +1375,11 @@ class JobCard(Document):
if not self.wip_warehouse:
self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse")
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
@@ -1418,15 +1446,46 @@ class JobCard(Document):
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=["operation", "status", "completed_qty", "sequence_id"],
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",
)
for row in previous_operations:
self.validate_previous_operation(row, current_operation_qty)
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):
current_operation_qty = 0.0
@@ -1455,13 +1514,42 @@ class JobCard(Document):
_(
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
).format(
bold(current_operation_qty),
bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation),
bold(row.completed_qty),
bold(self.get_qty_with_uom(row.completed_qty, row.finished_good)),
bold(row.operation),
)
)
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
manufactured_qty = flt(row.manufactured_qty)
if not manufactured_qty:
frappe.throw(
_(
"Job Card {0}: As per the sequence of the operations in the work order {1}, submit the manufacturing entry for the operation {2} before the operation {3}."
).format(
bold(self.name),
bold(get_link_to_form("Work Order", self.work_order)),
bold(row.operation),
bold(self.operation),
),
OperationSequenceError,
)
if manufactured_qty < current_operation_qty:
frappe.throw(
_(
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
).format(
bold(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,
)
def validate_work_order(self):
if self.is_work_order_closed():
frappe.throw(_("You cannot make any changes to Job Card since Work Order is closed."))
@@ -1598,6 +1686,7 @@ class JobCard(Document):
kwargs = frappe._dict(kwargs)
self.validate_complete_job_card_qty(kwargs)
self.set_for_quantity(kwargs)
self.pending_qty = flt(kwargs.pending_qty)
self.process_loss_qty = flt(kwargs.process_loss_qty)
@@ -1607,6 +1696,14 @@ class JobCard(Document):
if kwargs.auto_submit:
self.auto_submit_job_card(kwargs.auto_submit)
def set_for_quantity(self, kwargs):
"""Qty to Manufacture of the completion dialog covers the current cycle only,
so the qty completed by the earlier cycles of this job card is kept."""
if not flt(kwargs.for_quantity):
return
self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity)
def validate_docstatus(self):
if self.docstatus == 2:
frappe.throw(_("Cancelled Job Card cannot be processed."))
@@ -1624,6 +1721,29 @@ class JobCard(Document):
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) > self.for_quantity:
frappe.throw(_("Pending quantity cannot be greater than the for quantity."))
self.validate_completion_qty_split(kwargs)
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):
if kwargs.end_time:
self.add_time_logs(
@@ -1683,7 +1803,7 @@ class JobCard(Document):
return ManufactureEntry(
{
"for_quantity": self.for_quantity - self.manufactured_qty,
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty,
"process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0),
"job_card": self.name,
"skip_material_transfer": self.skip_material_transfer,

View File

@@ -10,6 +10,7 @@ from frappe.utils.data import add_to_date, now, today
from erpnext.manufacturing.doctype.job_card.job_card import (
JobCardOverTransferError,
OperationSequenceError,
OverlapError,
)
from erpnext.manufacturing.doctype.job_card.mapper import (
@@ -911,6 +912,82 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(wo_doc.process_loss_qty, 2)
self.assertEqual(wo_doc.status, "Completed")
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)
def test_op_cost_calculation(self):
from erpnext.manufacturing.doctype.routing.test_routing import (
create_routing,
@@ -1289,6 +1366,225 @@ class TestJobCard(ERPNextTestSuite):
8,
)
def test_semi_fg_pending_qty_is_left_to_another_job_card(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Pending Qty RM 1", {"is_stock_item": 1}).name
fg = make_item("Pending Qty FG 1", {"is_stock_item": 1}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
operation = {
"operation": "Pending Qty Op A",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = self.get_first_job_card(work_order.name)
job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=3,
for_quantity=5,
pending_qty=2,
process_loss_qty=0,
end_time="2024-04-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.for_quantity), 5)
self.assertEqual(flt(job_card.pending_qty), 2)
self.assertEqual(flt(job_card.process_loss_qty), 0)
job_card.submit()
self.assertEqual(job_card.status, "To Manufacture")
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item)
self.assertEqual(flt(finished_item.qty), 3)
manufacturing_entry.submit()
job_card.reload()
self.assertEqual(flt(job_card.manufactured_qty), 3)
self.assertEqual(job_card.status, "Completed")
def test_semi_fg_sequence_needs_previous_operations_manufactured(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm1 = make_item("Sequence Check RM 1", {"is_stock_item": 1}).name
rm2 = make_item("Sequence Check RM 2", {"is_stock_item": 1}).name
sfg1 = make_item("Sequence Check SFG 1", {"is_stock_item": 1}).name
sfg2 = make_item("Sequence Check SFG 2", {"is_stock_item": 1}).name
fg = make_item("Sequence Check FG 1", {"is_stock_item": 1}).name
semi_fg_boms = {}
for semi_fg_item, raw_material in ((sfg1, rm1), (sfg2, rm2)):
bom = frappe.new_doc("BOM", company="_Test Company", item=semi_fg_item, quantity=1)
bom.append("items", {"item_code": raw_material, "qty": 1})
bom.insert()
bom.submit()
semi_fg_boms[semi_fg_item] = bom.name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operations = [
{
"operation": "Sequence Check Op A",
"finished_good": sfg1,
"bom_no": semi_fg_boms[sfg1],
"sequence_id": 1,
},
{
"operation": "Sequence Check Op B",
"finished_good": sfg2,
"bom_no": semi_fg_boms[sfg2],
"sequence_id": 1,
},
{
"operation": "Sequence Check Op C",
"finished_good": fg,
"is_final_finished_good": 1,
"sequence_id": 2,
},
]
for row in operations:
row.update(
{
"workstation": "_Test Workstation A",
"finished_good_qty": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
)
make_workstation(row)
make_operation(row)
fg_bom.append("operations", row)
fg_bom.append("items", {"item_code": sfg1, "qty": 1, "operation_row_id": 3})
fg_bom.append("items", {"item_code": sfg2, "qty": 1, "operation_row_id": 3})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
for row in work_order.operations:
row.time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
def get_job_card(operation):
return frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card",
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
"name",
),
)
def add_time_log(job_card, day, qty):
job_card.append(
"time_logs",
{
"from_time": f"2024-01-{day} 08:00:00",
"to_time": f"2024-01-{day} 09:00:00",
"completed_qty": qty,
},
)
jc_a = get_job_card("Sequence Check Op A")
jc_a.for_quantity = 3
add_time_log(jc_a, "01", 3)
jc_a.submit()
jc_b = get_job_card("Sequence Check Op B")
add_time_log(jc_b, "02", jc_b.for_quantity)
jc_b.submit()
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
jc_c = get_job_card("Sequence Check Op C")
jc_c.for_quantity = 3
add_time_log(jc_c, "03", 3)
self.assertRaises(OperationSequenceError, jc_c.save)
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
jc_c.reload()
jc_c.for_quantity = 4
add_time_log(jc_c, "03", 4)
self.assertRaises(OperationSequenceError, jc_c.save)
jc_c.reload()
jc_c.for_quantity = 3
add_time_log(jc_c, "03", 3)
jc_c.submit()
self.assertEqual(jc_c.docstatus, 1)
def test_semi_fg_batch_auto_pull_on_manufacture(self):
"""Batch produced by an operation should auto-pull into the next operation's
semi-finished consumption row (skip-transfer Manufacture entry)."""
@@ -1940,6 +2236,28 @@ class TestJobCardLogic(ERPNextTestSuite):
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

View File

@@ -22,6 +22,7 @@ JOB_CARD_FIELDS = [
"total_completed_qty",
"for_quantity",
"process_loss_qty",
"stock_uom",
"finished_good",
"transferred_qty",
"status",

View File

@@ -507,3 +507,4 @@ erpnext.patches.v16_0.fix_subcontracting_titles
erpnext.patches.v16_0.move_warehouse_defaults_to_company
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.set_stock_uom_in_job_card

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)

View File

@@ -786,16 +786,20 @@ class ShopFloor {
pending = flt(jc.pending_qty);
}
const qty_with_uom = (qty) => `${flt(qty)} ${jc.stock_uom || ""}`.trim();
const fields = [
{
fieldtype: "Float",
label: __("Qty to Manufacture"),
label: __("Qty to Manufacture in this Cycle"),
fieldname: "for_quantity",
reqd: 1,
default: pending,
description: __("Completed, Pending and Process Loss quantities must add up to this."),
change() {
const d = me.session_dialog;
d.set_value("completed_qty", d.get_value("for_quantity"));
d.set_value("pending_qty", 0);
d.set_value("process_loss_qty", 0);
},
},
@@ -807,8 +811,23 @@ class ShopFloor {
default: pending,
change() {
const d = me.session_dialog;
const remaining = flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty"));
if (remaining > 0 && remaining !== flt(d.get_value("pending_qty"))) {
const remaining =
flt(d.get_value("for_quantity")) -
flt(d.get_value("completed_qty")) -
flt(d.get_value("process_loss_qty"));
if (remaining < 0) {
const max_completed_qty =
flt(d.get_value("for_quantity")) - flt(d.get_value("process_loss_qty"));
d.set_value("completed_qty", max_completed_qty);
frappe.throw(
__("Completed Quantity cannot be greater than {0}", [
qty_with_uom(max_completed_qty),
])
);
}
if (remaining !== flt(d.get_value("pending_qty"))) {
d.set_value("pending_qty", remaining);
}
},
@@ -818,13 +837,26 @@ class ShopFloor {
label: __("Pending Quantity"),
fieldname: "pending_qty",
default: 0.0,
description: __("Qty left for a later cycle or for another job card."),
change() {
const d = me.session_dialog;
const pl =
flt(d.get_value("for_quantity")) -
flt(d.get_value("completed_qty")) -
flt(d.get_value("pending_qty"));
if (pl >= 0 && pl !== flt(d.get_value("process_loss_qty"))) {
if (pl < 0) {
d.set_value("pending_qty", 0);
frappe.throw(
__("Pending Quantity cannot be greater than {0}", [
qty_with_uom(
flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty"))
),
])
);
}
if (pl !== flt(d.get_value("process_loss_qty"))) {
d.set_value("process_loss_qty", pl);
}
},
@@ -834,13 +866,26 @@ class ShopFloor {
label: __("Process Loss Quantity"),
fieldname: "process_loss_qty",
default: 0.0,
description: __("Qty scrapped in this cycle, nobody will produce it."),
change() {
const d = me.session_dialog;
const remaining =
flt(d.get_value("for_quantity")) -
flt(d.get_value("completed_qty")) -
flt(d.get_value("process_loss_qty"));
if (remaining >= 0 && remaining !== flt(d.get_value("pending_qty"))) {
if (remaining < 0) {
d.set_value("process_loss_qty", 0);
frappe.throw(
__("Process Loss Quantity cannot be greater than {0}", [
qty_with_uom(
flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty"))
),
])
);
}
if (remaining !== flt(d.get_value("pending_qty"))) {
d.set_value("pending_qty", remaining);
}
},