diff --git a/erpnext/manufacturing/doctype/job_card/mapper.py b/erpnext/manufacturing/doctype/job_card/mapper.py new file mode 100644 index 00000000000..1753be76bbd --- /dev/null +++ b/erpnext/manufacturing/doctype/job_card/mapper.py @@ -0,0 +1,214 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import flt + +from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on +from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( + get_subcontracting_boms_for_finished_goods, +) + + +@frappe.whitelist() +def make_subcontracting_po(source_name: str, target_doc: str | dict | Document | None = None): + def set_missing_values(source, target): + _item_details = get_subcontracting_boms_for_finished_goods(source.finished_good) + + pending_qty = source.for_quantity - source.manufactured_qty + service_item_qty = flt(_item_details.service_item_qty) or 1.0 + fg_item_qty = flt(_item_details.finished_good_qty) or 1.0 + + target.is_subcontracted = 1 + target.supplier_warehouse = source.wip_warehouse + target.append( + "items", + { + "item_code": _item_details.service_item, + "fg_item": source.finished_good, + "uom": _item_details.service_item_uom, + "stock_uom": _item_details.service_item_uom, + "conversion_factor": _item_details.conversion_factor or 1, + "item_name": _item_details.service_item, + "qty": pending_qty * service_item_qty / fg_item_qty, + "fg_item_qty": pending_qty, + "job_card": source.name, + "bom": source.semi_fg_bom, + "warehouse": source.target_warehouse, + }, + ) + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": {"doctype": "Purchase Order", "field_no_map": ["naming_series"]}, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_material_request(source_name: str, target_doc: str | dict | Document | None = None): + def update_item(obj, target, source_parent): + target.warehouse = source_parent.wip_warehouse + + def set_missing_values(source, target): + target.material_request_type = "Material Transfer" + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Material Request", + "field_map": { + "name": "job_card", + }, + }, + "Job Card Item": { + "doctype": "Material Request Item", + "field_map": {"required_qty": "qty", "uom": "stock_uom", "name": "job_card_item"}, + "postprocess": update_item, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_stock_entry(source_name: str, target_doc: str | dict | Document | None = None): + from erpnext.stock.doctype.stock_entry.services.manufacturing import ( + set_previous_operation_serial_batch, + ) + + def update_item(source, target, source_parent): + target.t_warehouse = source_parent.wip_warehouse + + if not target.conversion_factor: + target.conversion_factor = 1 + + pending_rm_qty = flt(source.required_qty) - flt(source.transferred_qty) + if pending_rm_qty > 0: + target.qty = pending_rm_qty + + def set_missing_values(source, target): + if not source.items: + frappe.throw(_("This Job Card has no raw materials to transfer.")) + + if source.finished_good and not source.target_warehouse: + frappe.throw(_("Please set the Target Warehouse in the Job Card")) + + if not source.skip_material_transfer or source.backflush_from_wip_warehouse: + if not source.wip_warehouse: + frappe.throw(_("Please set the WIP Warehouse in the Job Card")) + + target.purpose = "Material Transfer for Manufacture" + target.from_bom = 1 + + if source.semi_fg_bom: + target.bom_no = source.semi_fg_bom + + # avoid negative 'For Quantity' + pending_fg_qty = flt(source.get("for_quantity", 0)) - flt(source.get("transferred_qty", 0)) + target.fg_completed_qty = pending_fg_qty if pending_fg_qty > 0 else 0 + + target.set_missing_values() + target.set_stock_entry_type() + + wo_allows_alternate_item = frappe.db.get_value( + "Work Order", target.work_order, "allow_alternative_item" + ) + for item in target.items: + item.allow_alternative_item = int( + wo_allows_alternate_item + and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item") + ) + set_previous_operation_serial_batch(target, item) + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Stock Entry", + "field_map": {"name": "job_card", "for_quantity": "fg_completed_qty"}, + }, + "Job Card Item": { + "doctype": "Stock Entry Detail", + "field_map": { + "source_warehouse": "s_warehouse", + "required_qty": "qty", + "name": "job_card_item", + }, + "postprocess": update_item, + "condition": lambda doc: doc.required_qty > 0, + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_corrective_job_card( + source_name: str, + operation: str | None = None, + for_operation: str | None = None, + target_doc: str | dict | Document | None = None, +): + if not operation: + frappe.throw(_("Corrective Operation is required")) + + if not for_operation: + frappe.throw(_("For Operation is required")) + + def set_missing_values(source, target): + if source.track_semi_finished_goods: + frappe.throw( + _("Corrective Job Cards cannot be created for Work Orders that track semi-finished goods") + ) + + target.is_corrective_job_card = 1 + target.operation = operation + target.for_operation = for_operation + target.total_completed_qty = 0 + + target.set("time_logs", []) + target.set("employee", []) + target.set("items", []) + target.set("sub_operations", []) + target.set_sub_operations() + target.set_onload("backflush_raw_materials_based_on", get_backflush_based_on(target.bom_no)) + target.set_onload( + "transfer_material_against", + frappe.get_cached_value("Work Order", target.work_order, "transfer_material_against"), + ) + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Job Card", + "field_map": { + "name": "for_job_card", + }, + } + }, + target_doc, + set_missing_values, + ) + + return doclist diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index dec3e8f5817..b98f33f774a 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -3,18 +3,95 @@ import frappe from frappe import _ +from erpnext.manufacturing.doctype.job_card.mapper import make_stock_entry from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_bom from erpnext.manufacturing.doctype.workstation.workstation import ( NotInWorkingHoursError, WorkstationHolidayError, check_if_within_operating_hours, + get_raw_materials, update_job_card, ) from erpnext.tests.utils import ERPNextTestSuite class TestWorkstation(ERPNextTestSuite): + def test_get_raw_materials_without_items(self): + for skip_transfer, backflush_from_wip in ((0, 0), (1, 0), (1, 1)): + with self.subTest(skip_transfer=skip_transfer, backflush_from_wip=backflush_from_wip): + job_card = frappe.get_doc( + { + "doctype": "Job Card", + "company": "_Test Company", + "skip_material_transfer": skip_transfer, + "backflush_from_wip_warehouse": backflush_from_wip, + "wip_warehouse": "_Test Warehouse 1 - _TC", + } + ).insert(ignore_mandatory=True) + + for method in (get_raw_materials, make_stock_entry): + with self.subTest(method=method.__name__): + with self.assertRaisesRegex( + frappe.ValidationError, "This Job Card has no raw materials to transfer" + ): + method(job_card.name) + + job_card.reload() + self.assertFalse(job_card.items) + self.assertFalse(frappe.db.exists("Stock Entry", {"job_card": job_card.name})) + + def test_get_raw_materials_availability(self): + for skip_transfer, backflush_from_wip, transferred_qty in ( + (0, 0, 2), + (0, 0, 5), + (1, 0, 0), + (1, 1, 0), + ): + with self.subTest( + skip_transfer=skip_transfer, + backflush_from_wip=backflush_from_wip, + transferred_qty=transferred_qty, + ): + job_card = frappe.get_doc( + { + "doctype": "Job Card", + "company": "_Test Company", + "skip_material_transfer": skip_transfer, + "backflush_from_wip_warehouse": backflush_from_wip, + "wip_warehouse": "_Test Warehouse 1 - _TC", + "items": [ + { + "item_code": "_Test Item", + "source_warehouse": "_Test Warehouse - _TC", + "required_qty": 5, + "transferred_qty": transferred_qty, + }, + ], + } + ).insert(ignore_mandatory=True) + + materials = get_raw_materials(job_card.name) + + self.assertEqual(len(materials), 1) + material = materials[0] + warehouse = "_Test Warehouse 1 - _TC" if backflush_from_wip else "_Test Warehouse - _TC" + stock_qty = ( + frappe.db.get_value( + "Bin", {"item_code": "_Test Item", "warehouse": warehouse}, "actual_qty" + ) + or 0 + ) + self.assertEqual(material.item_code, "_Test Item") + self.assertEqual(material.required_qty, 5) + self.assertEqual(material.transferred_qty, transferred_qty) + self.assertEqual(material.warehouse, warehouse) + self.assertEqual(material.stock_qty, stock_qty) + self.assertEqual( + material.material_availability_status, + int(stock_qty >= 5) if skip_transfer else int(transferred_qty >= 5), + ) + def test_update_job_card_rejects_disallowed_method(self): # The whitelisted update_job_card endpoint must only run an allowlisted set of Job Card # methods. An arbitrary method name must be rejected (PermissionError) before the document diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index afc18e12d13..8dacf17dae2 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -346,8 +346,8 @@ def get_raw_materials(job_card: str): filters={"name": job_card}, ) - if not raw_materials: - return [] + if not raw_materials or not raw_materials[0].item_code: + frappe.throw(_("This Job Card has no raw materials to transfer.")) for row in raw_materials: warehouse = row.source_warehouse diff --git a/erpnext/public/js/templates/shop_floor_template.html b/erpnext/public/js/templates/shop_floor_template.html new file mode 100644 index 00000000000..09360ee873a --- /dev/null +++ b/erpnext/public/js/templates/shop_floor_template.html @@ -0,0 +1,1079 @@ + + + +
+ {{ mode === 'work_order' ? work_order : workstation }} + + + + {{ summary.active_count }}{% if (mode === 'workstation' && summary.capacity > 1) { %}/{{ summary.capacity }}{% } %} {{ __("Active") }} + + + + + {{ summary.queue_count }} {{ mode === 'work_order' ? __("Pending") : __("In Queue") }} + + + + + {{ summary.completed_count }} {{ __("Completed") }} + + {% if (mode === 'workstation' && oee) { %} + + + OEE {{ oee.oee !== null && oee.oee !== undefined ? oee.oee + '%' : '–' }} + + · {{ oee.availability !== null && oee.availability !== undefined ? 'A ' + oee.availability : 'A –' }} · P {{ oee.performance }} · Q {{ oee.quality }} + + + {% } %} +
+ + +{% if (slots.length > 0) { %} +
+ {% slots.forEach((slot) => { %} +
+
+ {% if (slot && slot._is_next_up) { %} +
+
+ {% if (slot.item_image) { %} + + {% } else { %} + {{ frappe.get_abbr(slot.finished_good || slot.production_item, 2) }} + {% } %} +
+
+
{{ slot.finished_good || slot.production_item }}
+
+ {% if (slot.is_subcontracted) { %} + {{ __("Subcontract") }} + {% } %} + {{ slot.name }} +
+
+ {{ slot.operation }} · {{ format_number(slot.for_quantity, null, 0) }} {{ slot.fg_uom || '' }} + {% if (slot.work_order) { %} · {{ slot.work_order }}{% } %} +
+
+
+ {% if (slot._materials_ready) { %} + + {% } else if (slot.materials && slot.materials.length) { %} + + {% } %} +
+
+ {% } else if (slot) { %} +
+
+
+
+ {% if (slot.item_image) { %} + + {% } else { %} + {{ frappe.get_abbr(slot.finished_good || slot.production_item, 2) }} + {% } %} +
+
+
{{ slot.finished_good || slot.production_item }}
+
+ {{ slot.name }} + {{ __(slot.status) }} + {% if (slot.is_subcontracted) { %} + {{ __("Subcontract") }} + {% } %} + {% if (slot.qc && slot.qc.status === 'Accepted') { %} + {{ __("QC Passed") }} + {% } else if (slot.qc && slot.qc.status === 'Rejected') { %} + {{ __("QC Rejected") }} + {% } else if (slot.qc && slot.qc.required) { %} + {{ __("QC Required") }} + {% } else if (slot.qc && slot.qc.has_checklist) { %} + {{ __("QC Available") }} + {% } %} +
+
+ {{ slot.operation }} · {{ format_number(slot.total_completed_qty, null, 0) }}/{{ format_number(slot.for_quantity, null, 0) }} {{ slot.fg_uom || '' }} + {% if (slot.work_order) { %} · {{ slot.work_order }}{% } %} +
+
+
+
+
+ 00:00:00 +
+
+ {% if (slot.is_paused) { %} + + {% } else { %} + + + {% } %} +
+
+
+
+ {% } else { %} +
+ {{ __("Slot available — start a job from the queue.") }} +
+ {% } %} + + {% if (slot && slot.materials && slot.materials.length > 0) { %} + {% var ready_n = slot.materials.filter((x) => x.status === 'ready').length; %} + {% var avail_n = slot.materials.filter((x) => x.status === 'available').length; %} + {% var short_n = slot.materials.filter((x) => x.status === 'short').length; %} +
+
+ + + {{ frappe.utils.icon("es-line-down", "sm") }} + {{ __("Materials") }} + {{ slot.materials.length }} + + {% if (ready_n > 0) { %}{% } %} + {% if (avail_n > 0) { %}{% } %} + {% if (short_n > 0) { %}{% } %} + + + {% if (slot.make_material_request && !slot._is_next_up) { %} + + {% } %} + +
+
+ {% slot.materials.forEach((m) => { %} + {% var color = m.status === 'ready' ? 'green' : (m.status === 'available' ? 'yellow' : 'red'); %} + {% var label = m.status === 'ready' ? __('Ready') : (m.status === 'available' ? __('Available') : __('Short')); %} +
+
+
{{ m.item_name }}
+
{{ m.item_code }}{% if (m.source_warehouse) { %} · {{ m.source_warehouse }}{% } %}
+
+
+
{{ format_number(m.transferred_qty, null, 2) }} / {{ format_number(m.required_qty, null, 2) }} {{ m.uom }}
+ {% if (m.status !== 'ready') { %} +
{{ __("In source") }}: {{ format_number(m.on_hand_qty, null, 2) }}
+ {% } %} +
+ {{ label }} +
+ {% }); %} +
+
+ {% } %} + + {% if (slot && slot.instructions) { %} +
+
+ {{ frappe.utils.icon("es-line-down", "sm") }} + {{ __("Work Instructions") }} +
+
+ {% if (slot.instructions.description) { %} + +
{{ frappe.utils.escape_html(slot.instructions.description) }}
+ {% } %} + {% if (slot.instructions.work_instruction) { %} + +
{%= slot.instructions.work_instruction %}
+ {% } %} +
+
+ {% } %} +
+
+ {% }); %} +
+{% } else if (!(completed && completed.length) && !(pending_submission && pending_submission.length) && !(to_manufacture && to_manufacture.length) && !(queue && queue.length) && !(today_sessions && today_sessions.length)) { %} +
+ {{ __("No active jobs and the queue is empty.") }} +
+{% } %} + + +{% if (pending_submission && pending_submission.length > 0) { %} +
+
+
+
{{ __("Ready to Submit") }}
+
+ {{ pending_submission.length === 1 ? __("1 draft job card awaiting submission") : __("{0} draft job cards awaiting submission", [pending_submission.length]) }} +
+
+
+
+ {% pending_submission.forEach((jc) => { %} +
+
+ {{ __("Qty Done") }} +
+
+
+ {% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %} +
+
{{ jc.finished_good || jc.production_item }}
+
+
{{ jc.operation }}
+ +
+ {{ format_number(jc.total_completed_qty, null, 0) }} / {{ format_number(jc.for_quantity, null, 0) }} + {{ jc.fg_uom || '' }} +
+
+
+ +
+
+ {% }); %} +
+
+{% } %} + + +{% if (to_manufacture && to_manufacture.length > 0) { %} +
+
+
+
{{ __("To Manufacture") }}
+
+ {{ to_manufacture.length === 1 ? __("1 job card awaiting Manufacture entry") : __("{0} job cards awaiting Manufacture entry", [to_manufacture.length]) }} +
+
+
+
+ {% to_manufacture.forEach((jc) => { %} +
+
+ {{ __("To Manufacture") }} +
+
+
+ {% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %} +
+
{{ jc.finished_good || jc.production_item }}
+
+
{{ jc.operation }}
+ +
+ {{ format_number(jc.total_completed_qty, null, 0) }} / {{ format_number(jc.for_quantity, null, 0) }} + {{ jc.fg_uom || '' }} +
+
+
+ +
+
+ {% }); %} +
+
+{% } %} + + +{% if (queue.length > 0) { %} +
+
+
+
{{ __("Up Next") }}
+
+ {{ queue.length === 1 ? __("1 pending job card") : __("{0} pending job cards", [queue.length]) }} +
+
+
+
+ {% queue.forEach((jc) => { %} +
+
+ {{ __(jc.status) }} + {% if (jc.is_subcontracted) { %}{{ __("Sub") }}{% } %} +
+
+
+ {% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %} +
+
{{ jc.finished_good || jc.production_item }}
+
+
{{ jc.operation }}
+ +
+ {{ format_number(jc.for_quantity, null, 0) }} + {{ jc.fg_uom || '' }} +
+
+ {{ jc._materials_ready ? __("Materials Ready") : __("Awaiting Transfer") }} +
+
+ {% if (jc._materials_ready) { %} + + {% } else if (jc.materials && jc.materials.length) { %} + + {% } %} +
+
+ {% }); %} +
+
+{% } %} + + +{% if (completed && completed.length > 0) { %} +
+
+
+
{{ __("Completed Operations") }}
+
+ {{ completed.length === 1 ? __("1 completed job card") : __("{0} completed job cards", [completed.length]) }} +
+
+
+
+ {% completed.forEach((jc) => { %} +
+
+ {{ __(jc.status) }} + {% if (jc.is_subcontracted) { %}{{ __("Sub") }}{% } %} +
+
+
+ {% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %} +
+
{{ jc.finished_good || jc.production_item }}
+
+
{{ jc.operation }}
+ +
+ {{ format_number(jc.total_completed_qty, null, 0) }} / {{ format_number(jc.for_quantity, null, 0) }} + {{ jc.fg_uom || '' }} +
+
+
+
+ {% }); %} +
+
+{% } %} + + +{% if (today_sessions && today_sessions.length > 0) { %} +
+
+
+
{{ __("Today's Sessions") }}
+
+ {{ today_sessions.length === 1 ? __("1 submitted today") : __("{0} submitted today", [today_sessions.length]) }} +
+
+
+
+ {% today_sessions.forEach((s) => { %} + {% var mins = cint(s.total_time_in_mins); var hh = Math.floor(mins / 60); var mm = mins % 60; var dur = mins ? (hh ? hh + 'h ' : '') + mm + 'm' : '—'; %} +
+
+ {{ __(s.status) }} +
+
+
+ {% if (s.item_image) { %}{% } else { %}{{ frappe.get_abbr(s.finished_good || s.production_item, 2) }}{% } %} +
+
{{ s.finished_good || s.production_item }}
+
+
{{ s.operation }}
+ +
+ {{ format_number(s.total_completed_qty, null, 0) }} / {{ format_number(s.for_quantity, null, 0) }} + {% if (cint(s.process_loss_qty) > 0) { %}
{{ __("Loss") }}: {{ format_number(s.process_loss_qty, null, 0) }}
{% } %} +
+
{{ __("Duration") }}: {{ dur }}
+
+
+ {% }); %} +
+
+{% } %}