mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-26 06:47:05 +00:00
Compare commits
14 Commits
develop
...
job-card-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5cb12c3d7 | ||
|
|
500b9073e7 | ||
|
|
d4d5bf5d86 | ||
|
|
a8f189696e | ||
|
|
02c066a634 | ||
|
|
f48538aeae | ||
|
|
2c131d5819 | ||
|
|
e042d09975 | ||
|
|
2565a56ade | ||
|
|
3fab303e51 | ||
|
|
6ef498e352 | ||
|
|
9815d90b0f | ||
|
|
fb763848da | ||
|
|
c2654c1380 |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ JOB_CARD_FIELDS = [
|
||||
"total_completed_qty",
|
||||
"for_quantity",
|
||||
"process_loss_qty",
|
||||
"stock_uom",
|
||||
"finished_good",
|
||||
"transferred_qty",
|
||||
"status",
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user