fix(manufacturing): handle empty raw materials in workstation (#58927)

(cherry picked from commit 3be0c7801a)

# Conflicts:
#	erpnext/manufacturing/doctype/job_card/mapper.py
#	erpnext/public/js/templates/shop_floor_template.html
This commit is contained in:
Pandiyan P
2026-09-09 18:05:03 +05:30
committed by Mergify
parent 33a74a2730
commit ec1d5a5613
4 changed files with 1372 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff