mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-27 05:45:19 +00:00
* fix(manufacturing): cap job card completed qty by previous operation and show process loss on finish dialog * fix: avoid double booking process loss on partial manufacture entries
This commit is contained in:
@@ -150,6 +150,8 @@ class JobCard(Document):
|
|||||||
self.set_onload("job_card_excess_transfer", excess_transfer)
|
self.set_onload("job_card_excess_transfer", excess_transfer)
|
||||||
self.set_onload("work_order_closed", self.is_work_order_closed())
|
self.set_onload("work_order_closed", self.is_work_order_closed())
|
||||||
self.set_onload("has_stock_entry", self.has_stock_entry())
|
self.set_onload("has_stock_entry", self.has_stock_entry())
|
||||||
|
if self.docstatus == 0:
|
||||||
|
self.set_onload("max_completable_qty", self.get_max_completable_qty())
|
||||||
|
|
||||||
def on_discard(self):
|
def on_discard(self):
|
||||||
self.db_set("status", "Cancelled")
|
self.db_set("status", "Cancelled")
|
||||||
@@ -1374,12 +1376,7 @@ class JobCard(Document):
|
|||||||
|
|
||||||
current_operation_qty += flt(self.total_completed_qty)
|
current_operation_qty += flt(self.total_completed_qty)
|
||||||
|
|
||||||
previous_operations = frappe.get_all(
|
previous_operations = self.get_previous_operations()
|
||||||
"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",
|
|
||||||
)
|
|
||||||
|
|
||||||
message = "Job Card {}: As per the sequence of the operations in the work order {}".format(
|
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))
|
bold(self.name), bold(get_link_to_form("Work Order", self.work_order))
|
||||||
@@ -1443,6 +1440,41 @@ class JobCard(Document):
|
|||||||
|
|
||||||
return dict(data)
|
return dict(data)
|
||||||
|
|
||||||
|
def get_previous_operations(self):
|
||||||
|
return 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",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_current_operation_completed_qty(self):
|
||||||
|
current_operation_qty = 0.0
|
||||||
|
data = self.get_current_operation_data()
|
||||||
|
if data and len(data) > 0:
|
||||||
|
current_operation_qty = flt(data[0].completed_qty)
|
||||||
|
|
||||||
|
return current_operation_qty + flt(self.total_completed_qty)
|
||||||
|
|
||||||
|
def get_max_completable_qty(self):
|
||||||
|
if self.is_corrective_job_card or not (self.work_order and self.sequence_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
previous_operations = self.get_previous_operations()
|
||||||
|
if not previous_operations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.track_semi_finished_goods:
|
||||||
|
totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations])
|
||||||
|
for row in previous_operations:
|
||||||
|
row.manufactured_qty = flt(totals.get(row.name))
|
||||||
|
|
||||||
|
qty_field = "manufactured_qty" if self.track_semi_finished_goods else "completed_qty"
|
||||||
|
min_completed_qty = min(flt(row.get(qty_field)) for row in previous_operations)
|
||||||
|
|
||||||
|
precision = self.precision("total_completed_qty")
|
||||||
|
return flt(min_completed_qty - self.get_current_operation_completed_qty(), precision)
|
||||||
|
|
||||||
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
|
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
|
||||||
manufactured_qty = flt(row.manufactured_qty)
|
manufactured_qty = flt(row.manufactured_qty)
|
||||||
|
|
||||||
|
|||||||
@@ -915,6 +915,146 @@ class TestJobCard(ERPNextTestSuite):
|
|||||||
self.assertEqual(wo_doc.process_loss_qty, 2)
|
self.assertEqual(wo_doc.process_loss_qty, 2)
|
||||||
self.assertEqual(wo_doc.status, "Completed")
|
self.assertEqual(wo_doc.status, "Completed")
|
||||||
|
|
||||||
|
def make_two_operation_work_order(self, qty=10):
|
||||||
|
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||||
|
create_routing,
|
||||||
|
setup_bom,
|
||||||
|
setup_operations,
|
||||||
|
)
|
||||||
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
|
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
{"operation": "Test Operation A1", "workstation": "Test Workstation A", "time_in_mins": 30},
|
||||||
|
{"operation": "Test Operation B1", "workstation": "Test Workstation A", "time_in_mins": 20},
|
||||||
|
]
|
||||||
|
|
||||||
|
warehouse = create_warehouse("Test Warehouse 123 for Job Card")
|
||||||
|
setup_operations(operations)
|
||||||
|
|
||||||
|
item_code = "Test Job Card Process Qty Item"
|
||||||
|
for item in [item_code, item_code + "RM 1", item_code + "RM 2"]:
|
||||||
|
if not frappe.db.exists("Item", item):
|
||||||
|
make_item(item, {"item_name": item, "stock_uom": "Nos", "is_stock_item": 1})
|
||||||
|
|
||||||
|
routing_doc = create_routing(routing_name="Testing Route", operations=operations)
|
||||||
|
bom_doc = setup_bom(
|
||||||
|
item_code=item_code,
|
||||||
|
routing=routing_doc.name,
|
||||||
|
raw_materials=[item_code + "RM 1", item_code + "RM 2"],
|
||||||
|
source_warehouse=warehouse,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in bom_doc.items:
|
||||||
|
make_stock_entry(item_code=row.item_code, target=row.source_warehouse, qty=qty, basic_rate=100)
|
||||||
|
|
||||||
|
return make_wo_order_test_record(
|
||||||
|
production_item=item_code,
|
||||||
|
bom_no=bom_doc.name,
|
||||||
|
qty=qty,
|
||||||
|
skip_transfer=1,
|
||||||
|
wip_warehouse=warehouse,
|
||||||
|
source_warehouse=warehouse,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_completion_qty_capped_by_previous_operation(self):
|
||||||
|
wo_doc = self.make_two_operation_work_order()
|
||||||
|
job_cards = frappe.get_all(
|
||||||
|
"Job Card",
|
||||||
|
filters={"work_order": wo_doc.name},
|
||||||
|
fields=["name", "sequence_id"],
|
||||||
|
order_by="sequence_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
jc1 = frappe.get_doc("Job Card", job_cards[0].name)
|
||||||
|
self.assertIsNone(jc1.get_max_completable_qty())
|
||||||
|
|
||||||
|
jc1.append(
|
||||||
|
"time_logs",
|
||||||
|
{"from_time": now(), "to_time": add_to_date(now(), minutes=30), "completed_qty": 8},
|
||||||
|
)
|
||||||
|
jc1.save()
|
||||||
|
jc1.submit()
|
||||||
|
self.assertEqual(jc1.process_loss_qty, 2)
|
||||||
|
|
||||||
|
jc2 = frappe.get_doc("Job Card", job_cards[1].name)
|
||||||
|
self.assertEqual(jc2.get_max_completable_qty(), 8)
|
||||||
|
|
||||||
|
jc2.append("time_logs", {"from_time": add_to_date(now(), minutes=40)})
|
||||||
|
jc2.save()
|
||||||
|
|
||||||
|
self.assertRaises(
|
||||||
|
frappe.ValidationError,
|
||||||
|
jc2.complete_job_card,
|
||||||
|
qty=10,
|
||||||
|
for_quantity=10,
|
||||||
|
pending_qty=0,
|
||||||
|
process_loss_qty=0,
|
||||||
|
end_time=add_to_date(now(), minutes=70),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.complete_second_operation_and_finish(wo_doc, jc2.name)
|
||||||
|
|
||||||
|
def complete_second_operation_and_finish(self, wo_doc, job_card):
|
||||||
|
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||||
|
make_stock_entry as make_stock_entry_for_wo,
|
||||||
|
)
|
||||||
|
|
||||||
|
jc2 = frappe.get_doc("Job Card", job_card)
|
||||||
|
jc2.time_logs[0].completed_qty = 7
|
||||||
|
jc2.time_logs[0].to_time = add_to_date(now(), minutes=70)
|
||||||
|
jc2.save()
|
||||||
|
self.assertEqual(jc2.process_loss_qty, 3)
|
||||||
|
jc2.submit()
|
||||||
|
|
||||||
|
se = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 10))
|
||||||
|
se.submit()
|
||||||
|
|
||||||
|
self.assertEqual(se.process_loss_qty, 3)
|
||||||
|
fg_qty = sum(d.qty for d in se.items if d.is_finished_item)
|
||||||
|
self.assertEqual(flt(fg_qty), 7)
|
||||||
|
|
||||||
|
wo_doc.reload()
|
||||||
|
self.assertEqual(wo_doc.produced_qty, 7)
|
||||||
|
self.assertEqual(wo_doc.process_loss_qty, 3)
|
||||||
|
self.assertEqual(wo_doc.status, "Completed")
|
||||||
|
|
||||||
|
def test_process_loss_booked_once_across_partial_entries(self):
|
||||||
|
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||||
|
make_stock_entry as make_stock_entry_for_wo,
|
||||||
|
)
|
||||||
|
|
||||||
|
wo_doc = self.make_two_operation_work_order()
|
||||||
|
job_cards = frappe.get_all(
|
||||||
|
"Job Card", filters={"work_order": wo_doc.name}, fields=["name"], order_by="sequence_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, row in enumerate(job_cards):
|
||||||
|
jc = frappe.get_doc("Job Card", row.name)
|
||||||
|
from_time = add_to_date(now(), minutes=index * 40)
|
||||||
|
jc.append(
|
||||||
|
"time_logs",
|
||||||
|
{"from_time": from_time, "to_time": add_to_date(from_time, minutes=30), "completed_qty": 7},
|
||||||
|
)
|
||||||
|
jc.save()
|
||||||
|
jc.submit()
|
||||||
|
self.assertEqual(jc.process_loss_qty, 3)
|
||||||
|
|
||||||
|
se1 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 5))
|
||||||
|
se1.submit()
|
||||||
|
self.assertEqual(se1.process_loss_qty, 3)
|
||||||
|
self.assertEqual(flt(sum(d.qty for d in se1.items if d.is_finished_item)), 2)
|
||||||
|
|
||||||
|
se2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 5))
|
||||||
|
se2.submit()
|
||||||
|
self.assertEqual(flt(se2.process_loss_qty), 0)
|
||||||
|
self.assertEqual(flt(sum(d.qty for d in se2.items if d.is_finished_item)), 5)
|
||||||
|
|
||||||
|
wo_doc.reload()
|
||||||
|
self.assertEqual(wo_doc.process_loss_qty, 3)
|
||||||
|
self.assertEqual(wo_doc.produced_qty, 7)
|
||||||
|
self.assertEqual(wo_doc.status, "Completed")
|
||||||
|
|
||||||
def get_first_job_card(self, work_order):
|
def get_first_job_card(self, work_order):
|
||||||
return frappe.get_doc(
|
return frappe.get_doc(
|
||||||
"Job Card",
|
"Job Card",
|
||||||
|
|||||||
@@ -1021,6 +1021,15 @@ erpnext.work_order = {
|
|||||||
return flt(max, precision("qty"));
|
return flt(max, precision("qty"));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get_pending_operation_process_loss: (frm) => {
|
||||||
|
if (!(frm.doc.operations || []).length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total_loss = Math.max(...frm.doc.operations.map((row) => flt(row.process_loss_qty)));
|
||||||
|
return flt(Math.max(total_loss - flt(frm.doc.process_loss_qty), 0), precision("qty"));
|
||||||
|
},
|
||||||
|
|
||||||
show_disassembly_prompt: function (frm) {
|
show_disassembly_prompt: function (frm) {
|
||||||
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
|
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
|
||||||
|
|
||||||
@@ -1077,6 +1086,11 @@ erpnext.work_order = {
|
|||||||
|
|
||||||
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
|
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
|
||||||
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;
|
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;
|
||||||
|
if (purpose === "Manufacture") {
|
||||||
|
max = flt(Math.max(max - flt(frm.doc.process_loss_qty), 0), precision("qty"));
|
||||||
|
}
|
||||||
|
const pending_process_loss =
|
||||||
|
purpose === "Manufacture" ? this.get_pending_operation_process_loss(frm) : 0;
|
||||||
|
|
||||||
let fields = [
|
let fields = [
|
||||||
{
|
{
|
||||||
@@ -1085,23 +1099,36 @@ erpnext.work_order = {
|
|||||||
fieldname: "qty",
|
fieldname: "qty",
|
||||||
description: __("Max: {0}", [max]),
|
description: __("Max: {0}", [max]),
|
||||||
default: max,
|
default: max,
|
||||||
|
onchange: function () {
|
||||||
|
if (pending_process_loss && frm.qty_prompt) {
|
||||||
|
frm.qty_prompt.set_value(
|
||||||
|
"finished_good_qty",
|
||||||
|
flt(Math.max(flt(this.value) - pending_process_loss, 0), precision("qty"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!additional_transfer_entry) {
|
if (pending_process_loss) {
|
||||||
fields.push({
|
fields.push(
|
||||||
fieldtype: "Check",
|
{
|
||||||
label: __("Consider Process Loss"),
|
fieldtype: "Float",
|
||||||
fieldname: "consider_process_loss",
|
label: __("Process Loss Qty"),
|
||||||
default: 0,
|
fieldname: "process_loss_qty",
|
||||||
onchange: function () {
|
default: pending_process_loss,
|
||||||
if (this.value) {
|
read_only: 1,
|
||||||
frm.qty_prompt.set_value("qty", max - frm.doc.process_loss_qty);
|
description: __("Process loss booked against the operations of this work order."),
|
||||||
} else {
|
|
||||||
frm.qty_prompt.set_value("qty", max);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
fieldtype: "Float",
|
||||||
|
label: __("Finished Good Qty"),
|
||||||
|
fieldname: "finished_good_qty",
|
||||||
|
default: flt(Math.max(max - pending_process_loss, 0), precision("qty")),
|
||||||
|
read_only: 1,
|
||||||
|
description: __("Actual quantity of the finished good that will be manufactured."),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -3323,7 +3323,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
|||||||
|
|
||||||
def get_pending_process_loss_qty(self):
|
def get_pending_process_loss_qty(self):
|
||||||
"""Loss this entry should still book: the job card's unbooked loss when the entry
|
"""Loss this entry should still book: the job card's unbooked loss when the entry
|
||||||
belongs to one, else the largest operation loss on the work order (legacy flow)."""
|
belongs to one, else the unbooked portion of the largest operation loss on the work order."""
|
||||||
if self.job_card:
|
if self.job_card:
|
||||||
job_card = frappe.get_doc("Job Card", self.job_card)
|
job_card = frappe.get_doc("Job Card", self.job_card)
|
||||||
return max(flt(job_card.process_loss_qty) - flt(job_card.get_consumed_process_loss()), 0)
|
return max(flt(job_card.process_loss_qty) - flt(job_card.get_consumed_process_loss()), 0)
|
||||||
@@ -3334,7 +3334,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
|||||||
filters={"parent": self.work_order},
|
filters={"parent": self.work_order},
|
||||||
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
|
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
|
||||||
)
|
)
|
||||||
return flt(data[0].process_loss_qty) if data else 0
|
max_operation_loss = flt(data[0].process_loss_qty) if data else 0
|
||||||
|
booked_loss = flt(frappe.db.get_value("Work Order", self.work_order, "process_loss_qty"))
|
||||||
|
return max(max_operation_loss - booked_loss, 0)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user