mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-14 07:01:56 +00:00
fix: require material transfer before job card start and completion
When the work order transfers material against Job Card, the Start Job
and Complete Job actions (and the whitelisted start_timer and
complete_job_card methods behind them) accepted work before any
Material Transfer for Manufacture existed; the transfer gate only fired
on job card submission.
Run validate_transfer_qty on both actions, and drop the finished_good
escape in materials_ready so the dashboard hides the buttons while
transfer is pending. Job cards that skip material transfer, corrective
job cards, and work orders transferring against Work Order are exempt,
as on submit.
(cherry picked from commit 808b2e2984)
# Conflicts:
# erpnext/manufacturing/doctype/job_card/job_card.js
# erpnext/manufacturing/doctype/job_card/job_card.py
This commit is contained in:
@@ -296,9 +296,130 @@ frappe.ui.form.on("Job Card", {
|
||||
prepare_timer_buttons: function (frm) {
|
||||
frm.trigger("make_dashboard");
|
||||
|
||||
<<<<<<< HEAD
|
||||
if (!frm.doc.started_time && !frm.doc.current_time) {
|
||||
frm.add_custom_button(__("Start Job"), () => {
|
||||
if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) {
|
||||
=======
|
||||
const { doc } = frm;
|
||||
const { time_logs, status } = doc;
|
||||
|
||||
// ── Determine which action buttons to show ────────────────────────
|
||||
const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty;
|
||||
const pending_transfer =
|
||||
has_items && doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty));
|
||||
const materials_ready = doc.skip_material_transfer || !pending_transfer;
|
||||
|
||||
let last_row = {};
|
||||
const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0;
|
||||
if (has_sub_ops_or_pending_qty && time_logs?.length) {
|
||||
last_row = get_last_row(time_logs);
|
||||
}
|
||||
|
||||
const no_time_logs_yet = !time_logs?.length;
|
||||
const pending_qty_cycle_done = flt(doc.pending_qty) > 0.0 && last_row?.to_time;
|
||||
const sub_operation_cycle_done = doc.sub_operations?.length && last_row?.to_time;
|
||||
const should_show_start =
|
||||
(no_time_logs_yet || pending_qty_cycle_done || sub_operation_cycle_done) && !doc.is_paused;
|
||||
|
||||
const last_log_complete = time_logs?.length && time_logs[time_logs.length - 1].to_time;
|
||||
const is_on_hold = status === "On Hold";
|
||||
const is_actively_running = !!(
|
||||
time_logs?.length &&
|
||||
!last_log_complete &&
|
||||
!is_on_hold &&
|
||||
!doc.is_paused
|
||||
);
|
||||
|
||||
let show_start = false,
|
||||
show_pause = false,
|
||||
show_resume = false,
|
||||
show_complete = false,
|
||||
is_timer_running = false;
|
||||
|
||||
if (has_remaining_qty && materials_ready) {
|
||||
const manufactured_qty = doc.manufactured_qty || doc.total_completed_qty;
|
||||
const qty_yet_to_manufacture = doc.for_quantity - (manufactured_qty + doc.process_loss_qty);
|
||||
|
||||
if (should_show_start) {
|
||||
show_start = true;
|
||||
} else if (doc.is_paused) {
|
||||
show_resume = true;
|
||||
} else if (qty_yet_to_manufacture > 0) {
|
||||
show_pause = true;
|
||||
show_complete = true;
|
||||
is_timer_running = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Timer color reflects job state ────────────────────────────────
|
||||
const [timer_color, timer_bg, timer_border] = [
|
||||
"var(--gray-600,#6b7280)",
|
||||
"var(--gray-100,#f3f4f6)",
|
||||
"var(--gray-300,#d1d5db)",
|
||||
];
|
||||
|
||||
// ── Action button HTML ────────────────────────────────────────────
|
||||
const btn = (cls, icon_path, label, icon_color) => `
|
||||
<button class="btn btn-sm ${cls}" style="display:inline-flex;align-items:center;gap:5px;font-weight:600;padding:6px 14px;">
|
||||
${frappe.utils.icon(icon_path, "sm", "", "", "", "", icon_color)}
|
||||
${label}
|
||||
</button>`;
|
||||
|
||||
const icons = {
|
||||
play: { d: '<polygon points="5 3 19 12 5 21 5 3"/>', fill: "currentColor", stroke: "none" },
|
||||
pause: {
|
||||
d: '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>',
|
||||
fill: "currentColor",
|
||||
stroke: "none",
|
||||
},
|
||||
check: { d: '<polyline points="20 6 9 17 4 12"/>', sw: 3 },
|
||||
};
|
||||
|
||||
const buttons_html = [
|
||||
show_start && btn("btn-primary jcd-btn-start", "play", __("Start Job")),
|
||||
show_resume && btn("btn-primary jcd-btn-resume", "play", __("Resume Job")),
|
||||
show_pause && btn("btn-default jcd-btn-pause", "pause", __("Pause Job")),
|
||||
show_complete && btn("btn-primary jcd-btn-complete", "check", __("Complete Job"), "white"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
|
||||
// ── Render widget ─────────────────────────────────────────────────
|
||||
wrapper.append(`
|
||||
<div class="job-card-dashboard-widget"
|
||||
style="border:1px solid var(--border-color);border-radius:var(--border-radius-lg,8px);
|
||||
background:var(--card-bg,#fff);padding:16px 20px;margin-bottom:16px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);font-weight:600;
|
||||
text-transform:uppercase;letter-spacing:0.6px;margin-bottom:6px;">
|
||||
${__("Elapsed Time")}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
${frappe.utils.icon("clock-4", "md", "", "", "", "", timer_color)}
|
||||
<span class="jcd-stopwatch"
|
||||
style="font-family:var(--monospace-font,'Courier New',monospace);
|
||||
font-size:28px;font-weight:700;letter-spacing:2px;color:${timer_color};">
|
||||
00:00:00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
${buttons_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
// ── Wire up button click handlers ─────────────────────────────────
|
||||
if (show_start) {
|
||||
wrapper.find(".jcd-btn-start").on("click", () => {
|
||||
const has_no_employee = !frm.doc.employee || !frm.doc.employee.length;
|
||||
|
||||
if (has_no_employee) {
|
||||
// Capture the start time only when the employee dialog is submitted, not on click,
|
||||
// so the time spent selecting the operator is not counted as worked time.
|
||||
>>>>>>> 808b2e2984 (fix: require material transfer before job card start and completion)
|
||||
frappe.prompt(
|
||||
{
|
||||
fieldtype: "Table MultiSelect",
|
||||
|
||||
@@ -1106,6 +1106,268 @@ class JobCard(Document):
|
||||
|
||||
frappe.db.set_value("Workstation", self.workstation, "status", status)
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
def add_time_logs(self, **kwargs):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
if not kwargs.employees and kwargs.to_time:
|
||||
for row in self.time_logs:
|
||||
if not row.to_time and row.from_time:
|
||||
row.to_time = kwargs.to_time
|
||||
row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
|
||||
|
||||
if kwargs.completed_qty:
|
||||
row.completed_qty = kwargs.completed_qty
|
||||
row.db_update()
|
||||
else:
|
||||
self.add_time_logs_for_employess(kwargs)
|
||||
|
||||
self.validate_time_logs(save=True)
|
||||
self.save()
|
||||
|
||||
def add_time_logs_for_employess(self, kwargs):
|
||||
update_status = False
|
||||
|
||||
for employee in kwargs.employees:
|
||||
kwargs.employee = employee.get("employee")
|
||||
if kwargs.from_time and not kwargs.to_time:
|
||||
self.add_new_time_log_for_employee(kwargs)
|
||||
elif not kwargs.from_time and not kwargs.to_time and kwargs.completed_qty:
|
||||
self.update_completed_qty_for_employee(kwargs)
|
||||
update_status = True
|
||||
else:
|
||||
self.close_time_log_for_employee(kwargs)
|
||||
update_status = True
|
||||
|
||||
self.set_status(update_status=update_status)
|
||||
|
||||
def add_new_time_log_for_employee(self, kwargs):
|
||||
if kwargs.qty:
|
||||
kwargs.completed_qty = kwargs.qty
|
||||
|
||||
row = self.append("time_logs", kwargs)
|
||||
row.db_update()
|
||||
self.db_set("status", "Work In Progress")
|
||||
|
||||
def update_completed_qty_for_employee(self, kwargs):
|
||||
for row in self.time_logs:
|
||||
if row.employee != kwargs.employee:
|
||||
continue
|
||||
|
||||
row.completed_qty = kwargs.completed_qty
|
||||
row.db_update()
|
||||
|
||||
def close_time_log_for_employee(self, kwargs):
|
||||
for row in self.time_logs:
|
||||
if row.to_time or row.employee != kwargs.employee:
|
||||
continue
|
||||
|
||||
row.to_time = kwargs.to_time
|
||||
row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time)
|
||||
if kwargs.get("sub_operation"):
|
||||
row.operation = kwargs.get("sub_operation")
|
||||
|
||||
if kwargs.employees[-1].get("employee") == row.employee:
|
||||
row.completed_qty = kwargs.completed_qty
|
||||
|
||||
row.db_update()
|
||||
|
||||
def update_workstation_status(self):
|
||||
status_map = {
|
||||
"Open": "Off",
|
||||
"Work In Progress": "Production",
|
||||
"Completed": "Off",
|
||||
"On Hold": "Idle",
|
||||
}
|
||||
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
fields=["name", "status"],
|
||||
filters={"workstation": self.workstation, "docstatus": 0, "status": ("!=", "Completed")},
|
||||
order_by="status desc",
|
||||
)
|
||||
|
||||
if not job_cards:
|
||||
frappe.db.set_value("Workstation", self.workstation, "status", "Off")
|
||||
|
||||
for row in job_cards:
|
||||
frappe.db.set_value("Workstation", self.workstation, "status", status_map.get(row.status))
|
||||
return
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_timer(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
self.validate_transfer_qty()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
if isinstance(kwargs.employees, str):
|
||||
kwargs.employees = [{"employee": kwargs.employees}]
|
||||
|
||||
if kwargs.start_time:
|
||||
self.add_time_logs(from_time=kwargs.start_time, employees=kwargs.employees)
|
||||
|
||||
@frappe.whitelist()
|
||||
def complete_job_card(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
self.validate_transfer_qty()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
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)
|
||||
|
||||
self.add_completion_time_logs(kwargs)
|
||||
|
||||
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."))
|
||||
|
||||
if self.docstatus == 1:
|
||||
frappe.throw(_("Submitted Job Card cannot be processed."))
|
||||
|
||||
def validate_complete_job_card_qty(self, kwargs):
|
||||
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0:
|
||||
frappe.throw(_("Pending quantity cannot be negative."))
|
||||
|
||||
if flt(kwargs.process_loss_qty) and flt(kwargs.process_loss_qty) < 0:
|
||||
frappe.throw(_("Process loss quantity cannot be negative."))
|
||||
|
||||
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(
|
||||
to_time=kwargs.end_time,
|
||||
completed_qty=kwargs.qty,
|
||||
employees=self.employee,
|
||||
sub_operation=kwargs.get("sub_operation"),
|
||||
)
|
||||
|
||||
if self.docstatus == 1:
|
||||
self.update_work_order()
|
||||
else:
|
||||
self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee)
|
||||
self.save()
|
||||
|
||||
def auto_submit_job_card(self, auto_submit):
|
||||
self.submit()
|
||||
|
||||
if not self.finished_good:
|
||||
return
|
||||
|
||||
self.make_stock_entry_for_semi_fg_item(auto_submit)
|
||||
frappe.msgprint(_("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name)))
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
frappe.has_permission("Stock Entry", "create", throw=True)
|
||||
|
||||
ste = self.build_manufacture_stock_entry()
|
||||
self.populate_manufacture_stock_entry(ste)
|
||||
|
||||
if auto_submit:
|
||||
ste.stock_entry.submit()
|
||||
else:
|
||||
ste.stock_entry.save()
|
||||
|
||||
frappe.msgprint(
|
||||
_("Stock Entry {0} has been created").format(
|
||||
get_link_to_form("Stock Entry", ste.stock_entry.name)
|
||||
)
|
||||
)
|
||||
|
||||
return ste.stock_entry.as_dict()
|
||||
|
||||
def get_consumed_process_loss(self):
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.process_loss_qty))
|
||||
.where((table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1))
|
||||
)
|
||||
return query.run()[0][0] or 0
|
||||
|
||||
def build_manufacture_stock_entry(self):
|
||||
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
|
||||
|
||||
consumed_process_loss = self.get_consumed_process_loss()
|
||||
return ManufactureEntry(
|
||||
{
|
||||
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss,
|
||||
"process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0),
|
||||
"job_card": self.name,
|
||||
"skip_material_transfer": self.skip_material_transfer,
|
||||
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,
|
||||
"work_order": self.work_order,
|
||||
"purpose": "Manufacture",
|
||||
"production_item": self.finished_good,
|
||||
"company": self.company,
|
||||
"wip_warehouse": self.wip_warehouse,
|
||||
"fg_warehouse": self.target_warehouse,
|
||||
"bom_no": self.semi_fg_bom,
|
||||
"project": frappe.db.get_value("Work Order", self.work_order, "project"),
|
||||
}
|
||||
)
|
||||
|
||||
def populate_manufacture_stock_entry(self, ste):
|
||||
from erpnext.stock.doctype.stock_entry.services.manufacturing import ManufactureStockEntry
|
||||
|
||||
ste.make_stock_entry()
|
||||
ste.stock_entry.flags.ignore_mandatory = True
|
||||
wo_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
add_additional_cost(ste.stock_entry, wo_doc, self)
|
||||
ManufactureStockEntry(ste.stock_entry).add_secondary_items_from_job_card()
|
||||
for row in ste.stock_entry.items:
|
||||
if (row.secondary_item_type or row.is_legacy_scrap_item) and not row.t_warehouse:
|
||||
row.t_warehouse = self.target_warehouse
|
||||
|
||||
>>>>>>> 808b2e2984 (fix: require material transfer before job card start and completion)
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_time_log(args):
|
||||
|
||||
Reference in New Issue
Block a user