From 7eb0291ad39d80767d074b2544908a3d5451d595 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 21 Oct 2025 23:50:28 +0530 Subject: [PATCH] fix: feat: multiple changes related to subcontracting inward --- .../doctype/sales_invoice/sales_invoice.py | 2 +- erpnext/controllers/accounts_controller.py | 4 +- .../controllers/subcontracting_controller.py | 4 +- .../subcontracting_inward_controller.py | 438 +++++++++++++----- .../doctype/work_order/work_order.js | 6 +- .../doctype/work_order/work_order.py | 138 ++++-- .../stock/doctype/stock_entry/stock_entry.js | 15 + .../stock/doctype/stock_entry/stock_entry.py | 3 +- .../stock_entry_detail.json | 16 +- .../stock_entry_detail/stock_entry_detail.py | 1 + .../subcontracting_inward_order.js | 8 + .../subcontracting_inward_order.py | 7 +- .../test_subcontracting_inward_order.py | 1 + .../subcontracting_inward_order_item.json | 3 +- ...ontracting_inward_order_received_item.json | 34 +- ...bcontracting_inward_order_received_item.py | 5 +- 16 files changed, 521 insertions(+), 164 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index eca61e9ea6..59c7db058d 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1332,7 +1332,7 @@ class SalesInvoice(SellingController): item.idx, item.stock_qty, item.stock_uom, - frappe.bold(item.item_code), + get_link_to_form("Item", item.item_code), frappe.bold(max_qty), ) ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index e325c837c8..5a76f30459 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -4177,8 +4177,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if parent.is_subcontracted and not parent.can_update_items(): frappe.throw( _( - "Items cannot be updated as Subcontracting Inward Order is created against the Sales Order {0}." - ).format(frappe.bold(parent.name)) + "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order." + ) ) parent.validate_selling_price() parent.validate_for_duplicate_items() diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 0b84d69773..adc5f6ae36 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -156,7 +156,7 @@ class SubcontractingController(StockController): frappe.throw( _( "Row {0}: Delivery Warehouse cannot be same as Customer Warehouse for Item {1}." - ).format(item.idx, frappe.bold(item.item_name)) + ).format(item.idx, get_link_to_form("Item", item.item_code)) ) if not item.get("is_scrap_item"): @@ -664,6 +664,8 @@ class SubcontractingController(StockController): def __add_supplied_or_received_item(self, item_row, bom_item, qty): bom_item.conversion_factor = item_row.conversion_factor + if self.subcontract_data.order_doctype == "Subcontracting Inward Order": + bom_item.pop("rate") rm_obj = self.append(self.raw_material_table, bom_item) if rm_obj.get("qty"): # Qty field not exists diff --git a/erpnext/controllers/subcontracting_inward_controller.py b/erpnext/controllers/subcontracting_inward_controller.py index b6954e26d9..f604fef572 100644 --- a/erpnext/controllers/subcontracting_inward_controller.py +++ b/erpnext/controllers/subcontracting_inward_controller.py @@ -9,7 +9,7 @@ from erpnext.stock.serial_batch_bundle import get_serial_batch_list_from_item class SubcontractingInwardController: def validate_subcontracting_inward(self): self.validate_inward_order() - self.validate_customer_provided_item_for_inward() + self.set_allow_zero_valuation_rate() self.validate_warehouse_() self.validate_serial_batch_for_return_or_delivery() self.validate_delivery() @@ -50,11 +50,22 @@ class SubcontractingInwardController: self.validate_material_receipt() case purpose if purpose in ["Return Raw Material to Customer", "Subcontracting Return"]: self.validate_returns() + case "Material Transfer for Manufacture": + self.validate_material_transfer() case "Manufacture": self.validate_manufacture() def validate_material_receipt(self): + rm_item_fg_combo = [] for item in self.items: + if not frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item"): + frappe.throw( + _("Row #{0}: Item {1} is not a Customer Provided Item.").format( + item.idx, + get_link_to_form("Item", item.item_code), + ) + ) + if ( item.scio_detail and frappe.get_cached_value( @@ -65,17 +76,44 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Item {1} mismatch. Changing of item code is not permitted, add another row instead." - ).format(item.idx, bold(item.item_code)) + ).format(item.idx, get_link_to_form("Item", item.item_code)) ) + if not item.scio_detail: # item is additional + if item.against_fg: + if (item.item_code, item.against_fg) not in rm_item_fg_combo: + rm_item_fg_combo.append((item.item_code, item.against_fg)) + else: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} against Subcontracting Inward Order Item {2} ({3}) cannot be added multiple times." + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + bold(item.against_fg), + get_link_to_form( + "Item", + frappe.get_cached_value( + "Subcontracting Inward Order Item", item.against_fg, "item_code" + ), + ), + ) + ) + else: + frappe.throw( + _( + "Row #{0}: Please select the Finished Good Item against which this Customer Provided Item will be used." + ).format(item.idx) + ) + def validate_returns(self): for item in self.items: if not item.scio_detail: frappe.throw( _("Row #{0}: Item {1} is not a part of Subcontracting Inward Order {2}").format( item.idx, - bold(item.item_code), - bold(self.subcontracting_inward_order), + get_link_to_form("Item", item.item_code), + get_link_to_form("Subcontracting Inward Order", self.subcontracting_inward_order), ) ) elif item.item_code != ( @@ -86,7 +124,7 @@ class SubcontractingInwardController: ): frappe.throw( _("Row #{0}: Item {1} mismatch. Changing of item code is not permitted.").format( - item.idx, bold(item.item_code) + item.idx, get_link_to_form("Item", item.item_code) ) ) @@ -101,7 +139,7 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Returned quantity cannot be greater than available quantity for Item {1}" - ).format(item.idx, bold(item.item_code)) + ).format(item.idx, get_link_to_form("Item", item.item_code)) ) else: data = frappe.get_value( @@ -114,16 +152,88 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Returned quantity cannot be greater than available quantity to return for Item {1}" - ).format(item.idx, bold(item.item_code)) + ).format(item.idx, get_link_to_form("Item", item.item_code)) ) - def validate_manufacture(self): - skip_transfer, customer_warehouse, wip_warehouse = frappe.get_cached_value( - "Work Order", - self.work_order, - ["skip_transfer", "source_warehouse", "wip_warehouse"], + def validate_material_transfer(self): + customer_warehouse = frappe.get_cached_value( + "Subcontracting Inward Order", self.subcontracting_inward_order, "customer_warehouse" ) - warehouse = customer_warehouse if skip_transfer else wip_warehouse + item_codes = [] + for item in self.items: + if not frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item"): + continue + elif item.s_warehouse != customer_warehouse: + frappe.throw( + _("Row #{0}: For Customer Provided Item {1}, Source Warehouse must be {2}").format( + item.idx, + get_link_to_form("Item", item.item_code), + get_link_to_form("Warehouse", customer_warehouse), + ) + ) + elif item.item_code in item_codes: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} cannot be added multiple times in the Subcontracting Inward process." + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + ) + ) + else: + work_order_items = frappe.get_all( + "Work Order Item", + {"parent": self.work_order, "docstatus": 1, "is_customer_provided_item": 1}, + ["item_code", "transferred_qty", "required_qty", "stock_reserved_qty"], + ) + wo_item_dict = frappe._dict( + { + wo_item.item_code: frappe._dict( + { + "transferred_qty": wo_item.transferred_qty, + "required_qty": wo_item.required_qty, + "stock_reserved_qty": wo_item.stock_reserved_qty, + } + ) + for wo_item in work_order_items + } + ) + if wo_item := wo_item_dict.get(item.item_code): + if wo_item.transferred_qty + item.transfer_qty > max( + wo_item.required_qty, wo_item.stock_reserved_qty + ): + frappe.throw( + _( + "Row #{0}: Overconsumption of Customer Provided Item {1} against Work Order {2} is not allowed in the Subcontracting Inward process." + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + get_link_to_form("Work Order", self.work_order), + ) + ) + else: + item_codes.append(item.item_code) + else: + frappe.throw( + _("Row #{0}: Customer Provided Item {1} is not a part of Work Order {2}").format( + item.idx, + get_link_to_form("Item", item.item_code), + get_link_to_form("Work Order", self.work_order), + ) + ) + + def validate_manufacture(self): + if next(item for item in self.items if item.is_finished_item).t_warehouse != ( + fg_warehouse := frappe.get_cached_value("Work Order", self.work_order, "fg_warehouse") + ): + frappe.throw( + _( + "Target Warehouse for Finished Good must be same as Finished Good Warehouse {1} in Work Order {2} linked to the Subcontracting Inward Order." + ).format( + get_link_to_form("Warehouse", fg_warehouse), + get_link_to_form("Work Order", self.work_order), + ) + ) items = [ item @@ -133,74 +243,133 @@ class SubcontractingInwardController: and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item") ] - table = frappe.qb.DocType("Subcontracting Inward Order Received Item") - query = ( - frappe.qb.from_(table) - .select( - table.rm_item_code, - (table.received_qty - table.returned_qty).as_("total_qty"), - table.consumed_qty, - table.name, - ) - .where( - (table.docstatus == 1) - & (table.parent == self.subcontracting_inward_order) - & (table.main_item_code == frappe.get_cached_value("BOM", self.bom_no, "item")) - & (table.warehouse == warehouse) - & (table.rm_item_code.isin([item.item_code for item in items])) - ) + customer_warehouse = frappe.get_cached_value( + "Subcontracting Inward Order", self.subcontracting_inward_order, "customer_warehouse" ) - rm_item_dict = frappe._dict( - { - d.rm_item_code: frappe._dict( - {"name": d.name, "total_qty": d.total_qty, "qty": d.consumed_qty} + if frappe.get_cached_value("Work Order", self.work_order, "skip_transfer"): + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + query = ( + frappe.qb.from_(table) + .select( + table.rm_item_code, + (table.received_qty - table.returned_qty).as_("total_qty"), + table.consumed_qty, + table.name, ) - for d in query.run(as_dict=True) - } - ) + .where( + (table.docstatus == 1) + & (table.parent == self.subcontracting_inward_order) + & ( + table.reference_name + == frappe.get_cached_value( + "Work Order", self.work_order, "subcontracting_inward_order_item" + ) + ) + & (table.rm_item_code.isin([item.item_code for item in items])) + ) + ) + rm_item_dict = frappe._dict( + { + d.rm_item_code: frappe._dict( + {"name": d.name, "total_qty": d.total_qty, "qty": d.consumed_qty} + ) + for d in query.run(as_dict=True) + } + ) - for item in items: - if rm := rm_item_dict.get(item.item_code): - if rm.qty + item.transfer_qty > rm.total_qty: + item_codes = [] + for item in items: + if rm := rm_item_dict.get(item.item_code): + if rm.qty + item.transfer_qty > rm.total_qty: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} exceeds quantity available through Subcontracting Inward Order" + ).format(item.idx, get_link_to_form("Item", item.item_code), item.transfer_qty) + ) + elif item.s_warehouse != customer_warehouse: + frappe.throw( + _( + "Row #{0}: For Customer Provided Item {1}, Source Warehouse must be {2}" + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + get_link_to_form("Warehouse", customer_warehouse), + ) + ) + elif item.item_code in item_codes: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} cannot be added multiple times in the Subcontracting Inward process." + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + ) + ) + else: + item_codes.append(item.item_code) + else: frappe.throw( _( - "Row #{0}: Customer Provided Item {1} exceeds quantity available through Subcontracting Inward Order" - ).format(item.idx, bold(item.item_code), item.transfer_qty) + "Row #{0}: Customer Provided Item {1} is not a part of Subcontracting Inward Order {2}" + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + get_link_to_form("Subcontracting Inward Order", self.subcontracting_inward_order), + ) + ) + else: + work_order_items = frappe.get_all( + "Work Order Item", + {"parent": self.work_order, "docstatus": 1, "is_customer_provided_item": 1}, + ["item_code", "transferred_qty", "consumed_qty"], + ) + wo_item_dict = frappe._dict( + { + wo_item.item_code: frappe._dict( + {"transferred_qty": wo_item.transferred_qty, "consumed_qty": wo_item.consumed_qty} ) - elif item.s_warehouse != warehouse: + for wo_item in work_order_items + } + ) + item_codes = [] + for item in items: + if wo_item := wo_item_dict.get(item.item_code): + if wo_item.consumed_qty + item.transfer_qty > wo_item.transferred_qty: + frappe.throw( + _( + "Row #{0}: Overconsumption of Customer Provided Item {1} against Work Order {2} is not allowed in the Subcontracting Inward process." + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + get_link_to_form("Work Order", self.work_order), + ) + ) + elif item.item_code in item_codes: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} cannot be added multiple times in the Subcontracting Inward process." + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + ) + ) + else: + item_codes.append(item.item_code) + else: frappe.throw( - _("Row #{0}: For Customer Provided Item {1}, Source Warehouse must be {2}").format( + _("Row #{0}: Customer Provided Item {1} is not a part of Work Order {2}").format( item.idx, - bold(item.item_code), - bold(warehouse), + get_link_to_form("Item", item.item_code), + get_link_to_form("Work Order", self.work_order), ) ) - else: - frappe.throw( - _( - "Row #{0}: Customer Provided Item {1} is not a part of Subcontracting Inward Order {2}" - ).format( - item.idx, - bold(item.item_code), - bold(self.subcontracting_inward_order), - ) - ) - def validate_customer_provided_item_for_inward(self): + def set_allow_zero_valuation_rate(self): if self.subcontracting_inward_order: - if self.purpose in ["Subcontracting Delivery", "Subcontracting Return"]: + if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]: for item in self.items: if (item.is_finished_item or item.is_scrap_item) and item.valuation_rate == 0: item.allow_zero_valuation_rate = 1 - elif self.purpose == "Receive from Customer": - for item in self.items: - if not frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item"): - frappe.throw( - _("Row #{0}: Item {1} is not a customer provided item.").format( - item.idx, - get_link_to_form("Item", item.item_code), - ) - ) def validate_warehouse_(self): if self.subcontracting_inward_order and self.purpose in [ @@ -222,13 +391,13 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Target Warehouse must be same as Customer Warehouse {1} from the linked Subcontracting Inward Order" - ).format(item.idx, bold(customer_warehouse)) + ).format(item.idx, get_link_to_form("Warehouse", customer_warehouse)) ) else: frappe.throw( _( "Row #{0}: Source Warehouse must be same as Customer Warehouse {1} from the linked Subcontracting Inward Order" - ).format(item.idx, bold(customer_warehouse)) + ).format(item.idx, get_link_to_form("Warehouse", customer_warehouse)) ) def validate_serial_batch_for_return_or_delivery(self): @@ -249,7 +418,10 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Serial No(s) {1} are not a part of the linked Subcontracting Inward Order. Please select valid Serial No(s)." - ).format(item.idx, ", ".join([bold(sn) for sn in incorrect_serial_nos])) + ).format( + item.idx, + ", ".join([get_link_to_form("Serial No", sn) for sn in incorrect_serial_nos]), + ) ) if batch_list and ( incorrect_batch_nos := [bn for bn in batch_list if bn not in list(batch_nos.keys())] @@ -257,7 +429,10 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Batch No(s) {1} is not a part of the linked Subcontracting Inward Order. Please select valid Batch No(s)." - ).format(item.idx, ", ".join([bold(bn) for bn in incorrect_batch_nos])) + ).format( + item.idx, + ", ".join([get_link_to_form("Batch No", bn) for bn in incorrect_batch_nos]), + ) ) def get_serial_nos_and_batches_from_sres(self, scio_detail, only_pending=True): @@ -302,7 +477,7 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Cannot cancel this Stock Entry as returned quantity cannot be greater than delivered quantity for Item {1} in the linked Subcontracting Inward Order" - ).format(item.idx, bold(item.item_code)) + ).format(item.idx, get_link_to_form("Item", item.item_code)) ) def validate_delivery_on_save(self): @@ -315,8 +490,8 @@ class SubcontractingInwardController: frappe.throw( _("Row #{0}: Item {1} is not a part of Subcontracting Inward Order {2}").format( item.idx, - bold(item.item_code), - bold(self.subcontracting_inward_order), + get_link_to_form("Item", item.item_code), + get_link_to_form("Subcontracting Inward Order", self.subcontracting_inward_order), ) ) @@ -359,7 +534,7 @@ class SubcontractingInwardController: "Row #{0}: Quantity of Item {1} cannot be more than {2} {3} against Subcontracting Inward Order {4}" ).format( item.idx, - bold(item.item_code), + get_link_to_form("Item", item.item_code), bold(max_allowed_qty), bold( frappe.get_cached_value( @@ -370,7 +545,7 @@ class SubcontractingInwardController: "stock_uom", ) ), - bold(self.subcontracting_inward_order), + get_link_to_form("Subcontracting Inward Order", self.subcontracting_inward_order), ) ) @@ -400,7 +575,6 @@ class SubcontractingInwardController: & (table.voucher_type == "Subcontracting Inward Order") & (table.voucher_no == self.subcontracting_inward_order) & (table.voucher_detail_no == item.scio_detail) - & (table.warehouse == item.s_warehouse) ) .orderby(table.creation) ) @@ -522,7 +696,7 @@ class SubcontractingInwardController: ) < scio_rm_item.work_order_qty: frappe.throw( _("Row #{0}: Work Order exists against full or partial quantity of Item {1}").format( - item.idx, bold(item.item_code) + item.idx, get_link_to_form("Item", item.item_code) ) ) @@ -561,7 +735,7 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Scrap Item {1} produced cannot be less than quantity delivered." - ).format(item.idx, bold(item.item_code)) + ).format(item.idx, get_link_to_form("Item", item.item_code)) ) else: scio_rm_item = frappe.get_value( @@ -570,7 +744,7 @@ class SubcontractingInwardController: "docstatus": 1, "rm_item_code": item.item_code, "warehouse": item.s_warehouse, - "reference_name": fg_item_name, # if this field is set then the additional item is NOT customer provided + "is_customer_provided_item": 0, "is_additional_item": 1, }, ["consumed_qty", "billed_qty", "returned_qty"], @@ -582,7 +756,7 @@ class SubcontractingInwardController: frappe.throw( _( "Row #{0}: Cannot cancel this Manufacturing Stock Entry as billed quantity of Item {1} cannot be greater than consumed quantity." - ).format(item.idx, bold(item.item_code)) + ).format(item.idx, get_link_to_form("Item", item.item_code)) ) def update_inward_order_item(self): @@ -638,7 +812,9 @@ class SubcontractingInwardController: data = frappe._dict() for item in self.items: if item.scio_detail: - data[item.scio_detail] = item.transfer_qty if self._action == "submit" else -item.transfer_qty + data[item.scio_detail] = frappe._dict( + {"transfer_qty": item.transfer_qty, "rate": item.customer_provided_item_cost} + ) else: scio_rm = frappe.new_doc( "Subcontracting Inward Order Received Item", @@ -657,9 +833,16 @@ class SubcontractingInwardController: consumed_qty=0, work_order_qty=0, returned_qty=0, + rate=item.customer_provided_item_cost, + is_customer_provided_item=True, is_additional_item=True, + reference_name=item.against_fg, + main_item_code=frappe.get_cached_value( + "Subcontracting Inward Order Item", item.against_fg, "item_code" + ), ) scio_rm.insert() + scio_rm.submit() item.db_set("scio_detail", scio_rm.name) if data: @@ -670,39 +853,47 @@ class SubcontractingInwardController: "name": ["in", list(data.keys())], "docstatus": 1, }, - fields=["name", "required_qty", "received_qty"], + fields=["rate", "name", "required_qty", "received_qty"], ) deleted_docs = [] table = frappe.qb.DocType("Subcontracting Inward Order Received Item") - case_expr = Case() + case_expr_qty, case_expr_rate = Case(), Case() for d in result: - d.received_qty += data[d.name] + d.received_qty += ( + data[d.name].transfer_qty if self._action == "submit" else -data[d.name].transfer_qty + ) + d.rate += data[d.name].rate if self._action == "submit" else -data[d.name].rate if not d.required_qty and not d.received_qty: deleted_docs.append(d.name) frappe.delete_doc("Subcontracting Inward Order Received Item", d.name) else: - case_expr = case_expr.when(table.name == d.name, d.received_qty) + case_expr_qty = case_expr_qty.when(table.name == d.name, d.received_qty) + case_expr_rate = case_expr_rate.when(table.name == d.name, d.rate) - if len(list(set(data.keys()) - set(deleted_docs))) > 0: - frappe.qb.update(table).set(table.received_qty, case_expr).where( - (table.name.isin(list(set(data.keys()) - set(deleted_docs)))) & (table.docstatus == 1) - ).run() + if final_list := list(set(data.keys()) - set(deleted_docs)): + frappe.qb.update(table).set(table.received_qty, case_expr_qty).set( + table.rate, case_expr_rate + ).where((table.name.isin(final_list)) & (table.docstatus == 1)).run() def update_inward_order_received_items_for_manufacture(self): + customer_warehouse = frappe.get_cached_value( + "Subcontracting Inward Order", self.subcontracting_inward_order, "customer_warehouse" + ) items = [item for item in self.items if not item.is_finished_item and not item.is_scrap_item] item_code_wh = frappe._dict( { - (item.item_code, item.s_warehouse): item.transfer_qty - if self._action == "submit" - else -item.transfer_qty + ( + item.item_code, + customer_warehouse + if frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item") + else item.s_warehouse, + ): item.transfer_qty if self._action == "submit" else -item.transfer_qty for item in items } ) item_codes, warehouses = zip(*list(item_code_wh.keys()), strict=True) - item_codes = list(item_codes) - warehouses = list(warehouses) table = frappe.qb.DocType("Subcontracting Inward Order Received Item") data = ( @@ -712,22 +903,21 @@ class SubcontractingInwardController: table.rm_item_code, table.is_customer_provided_item, table.consumed_qty, - table.required_qty, table.warehouse, + table.is_additional_item, ) .where( (table.docstatus == 1) - & (table.rm_item_code.isin(item_codes)) - & ((table.warehouse.isin(warehouses)) | (table.warehouse.isnull())) + & (table.rm_item_code.isin(list(set(item_codes)))) + & ( + (table.warehouse.isin(list(set(warehouses)))) | (table.warehouse.isnull()) + ) # warehouse will always be null for non additional self procured raw materials & (table.parent == self.subcontracting_inward_order) & ( - ( - table.reference_name - == frappe.get_cached_value( - "Work Order", self.work_order, "subcontracting_inward_order_item" - ) + table.reference_name + == frappe.get_cached_value( + "Work Order", self.work_order, "subcontracting_inward_order_item" ) - | (table.reference_name.isnull()) ) ) ) @@ -745,25 +935,26 @@ class SubcontractingInwardController: used_item_wh.append((d.rm_item_code, d.warehouse)) qty = d.consumed_qty + item_code_wh[(d.rm_item_code, d.warehouse)] - if qty or d.is_customer_provided_item: + if qty or d.is_customer_provided_item or not d.is_additional_item: case_expr = case_expr.when((table.name == d.name), qty) else: deleted_docs.append(d.name) frappe.delete_doc("Subcontracting Inward Order Received Item", d.name) - final_name_list = list(set([d.name for d in data]) - set(deleted_docs)) - if len(final_name_list) > 0: + if final_list := list(set([d.name for d in data]) - set(deleted_docs)): frappe.qb.update(table).set(table.consumed_qty, case_expr).where( - (table.name.isin(final_name_list)) & (table.docstatus == 1) + (table.name.isin(final_list)) & (table.docstatus == 1) ).run() main_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code for extra_item in [ item for item in items - if (item.item_code, item.s_warehouse) not in [(d.rm_item_code, d.warehouse) for d in data] + if not frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item") + and (item.item_code, item.s_warehouse) + not in [(d.rm_item_code, d.warehouse) for d in data if not d.is_customer_provided_item] ]: - frappe.new_doc( + doc = frappe.new_doc( "Subcontracting Inward Order Received Item", parent=self.subcontracting_inward_order, parenttype="Subcontracting Inward Order", @@ -783,7 +974,9 @@ class SubcontractingInwardController: consumed_qty=extra_item.transfer_qty, warehouse=extra_item.s_warehouse, is_additional_item=True, - ).insert() + ) + doc.insert() + doc.submit() def update_inward_order_scrap_items(self): if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture": @@ -835,8 +1028,9 @@ class SubcontractingInwardController: table.name == value.name, value.produced_qty + scrap_items.get(key) ) - final_list = list(set([v.name for v in scrap_item_dict.values()]) - set(deleted_docs)) - if len(final_list) > 0: + if final_list := list( + set([v.name for v in scrap_item_dict.values()]) - set(deleted_docs) + ): frappe.qb.update(table).set(table.produced_qty, case_expr).where( (table.name.isin(final_list)) & (table.docstatus == 1) ).run() @@ -847,7 +1041,7 @@ class SubcontractingInwardController: for item in scrap_items_list if (item.item_code, item.t_warehouse) not in [(d.item_code, d.warehouse) for d in result] ]: - frappe.new_doc( + doc = frappe.new_doc( "Subcontracting Inward Order Scrap Item", parent=scio, parenttype="Subcontracting Inward Order", @@ -862,7 +1056,9 @@ class SubcontractingInwardController: reference_name=frappe.get_value( "Work Order", self.work_order, "subcontracting_inward_order_item" ), - ).insert() + ) + doc.insert() + doc.submit() def cancel_stock_reservation_entries_for_inward(self): if self.purpose == "Receive from Customer": @@ -1022,3 +1218,17 @@ class SubcontractingInwardController: ) update_subcontracting_inward_order_status(self.subcontracting_inward_order) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_fg_reference_names(doctype, txt, searchfield, start, page_len, filters): + return frappe.get_all( + "Subcontracting Inward Order Item", + limit_start=start, + limit_page_length=page_len, + filters={"parent": filters.get("parent"), "item_code": ("like", "%%%s%%" % txt), "docstatus": 1}, + fields=["name", "item_code", "delivery_warehouse"], + as_list=True, + order_by="idx", + ) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index fda8b515a4..5d739a7fd8 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -652,7 +652,11 @@ frappe.ui.form.on("Work Order Item", { required_qty: row.required_qty || 1, item_name: r.message.item_name, description: r.message.description, - source_warehouse: r.message.default_warehouse, + source_warehouse: + r.message.is_customer_provided_item && + frm.doc.subcontracting_inward_order_item + ? frm.doc.source_warehouse + : r.message.default_warehouse, allow_alternative_item: r.message.allow_alternative_item, include_item_in_manufacturing: r.message.include_item_in_manufacturing, }); diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 85b573cfca..8abda8f9d3 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -185,8 +185,6 @@ class WorkOrder(Document): if not self.subcontracting_inward_order: self.validate_sales_order() - else: - self.validate_self_rm_warehouse() self.set_default_warehouse() self.validate_warehouse_belongs_to_company() @@ -276,13 +274,100 @@ class WorkOrder(Document): ): frappe.throw( _( - "Source Warehouse {0} must be same as Customer Warehouse {1} in the Subcontracting Inward Order" + "Source Warehouse {0} must be same as Customer Warehouse {1} in the Subcontracting Inward Order." + ).format( + get_link_to_form("Warehouse", self.source_warehouse), + get_link_to_form("Warehouse", rm_receipt_warehouse), + ) + ) + + if self.fg_warehouse != ( + delivery_warehouse := frappe.get_cached_value( + "Subcontracting Inward Order Item", + self.subcontracting_inward_order_item, + "delivery_warehouse", + ) + ): + frappe.throw( + _( + "Target Warehouse {0} must be same as Delivery Warehouse {1} in the Subcontracting Inward Order Item." ).format( - frappe.bold(self.source_warehouse), - frappe.bold(rm_receipt_warehouse), + get_link_to_form("Warehouse", self.fg_warehouse), + get_link_to_form( + "Warehouse", + delivery_warehouse, + ), ) ) + possible_customer_provided_items = frappe.get_all( + "Subcontracting Inward Order Received Item", + { + "reference_name": self.subcontracting_inward_order_item, + "is_customer_provided_item": 1, + "docstatus": 1, + }, + ["rm_item_code", "received_qty", "returned_qty", "work_order_qty"], + ) + item_codes = [] + for item in self.required_items: + if item.is_customer_provided_item: + if item.source_warehouse != self.source_warehouse: + frappe.throw( + _( + "Row #{0}: Source Warehouse {1} for item {2} must be same as Source Warehouse {3} in the Work Order." + ).format( + item.idx, + get_link_to_form("Warehouse", item.source_warehouse), + get_link_to_form("Item", item.item_code), + get_link_to_form("Warehouse", self.source_warehouse), + ) + ) + elif item.item_code in item_codes: + frappe.throw( + _("Row #{0}: Customer Provided Item {1} cannot be added multiple times.").format( + item.idx, + get_link_to_form("Item", item.item_code), + ) + ) + else: + row = next( + (i for i in possible_customer_provided_items if i.rm_item_code == item.item_code), + None, + ) + if row: + if item.required_qty > row.received_qty - row.returned_qty - row.work_order_qty: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} has insufficient quantity in the Subcontracting Inward Order. Available quantity is {2}." + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + frappe.bold(row.received_qty - row.returned_qty - row.work_order_qty), + ) + ) + else: + item_codes.append(item.item_code) + else: + frappe.throw( + _( + "Row #{0}: Customer Provided Item {1} does not exist in the Required Items table linked to the Subcontracting Inward Order." + ).format( + item.idx, + get_link_to_form("Item", item.item_code), + ) + ) + elif frappe.get_cached_value("Warehouse", item.source_warehouse, "customer"): + frappe.throw( + _( + "Row #{0}: Source Warehouse {1} for item {2} cannot be a customer warehouse." + ).format( + item.idx, + get_link_to_form("Warehouse", item.source_warehouse), + get_link_to_form("Item", item.item_code), + ) + ) + def set_warehouses(self): for row in self.required_items: if not row.source_warehouse: @@ -356,15 +441,6 @@ class WorkOrder(Document): else: frappe.throw(_("Sales Order {0} is not valid").format(self.sales_order)) - def validate_self_rm_warehouse(self): - for item in [item for item in self.required_items if not item.is_customer_provided_item]: - if frappe.get_cached_value("Warehouse", item.source_warehouse, "customer"): - frappe.throw( - _("Row #{0}: Source Warehouse {1} for item {2} cannot be a customer warehouse.").format( - item.idx, frappe.bold(item.source_warehouse), frappe.bold(item.item_code) - ) - ) - def check_sales_order_on_hold_or_close(self): status = frappe.db.get_value("Sales Order", self.sales_order, "status") if status in ("Closed", "On Hold"): @@ -703,33 +779,40 @@ class WorkOrder(Document): def set_qty_change(self): if scio_item_name := self.get("subcontracting_inward_order_item"): - scio_rm_item_names = frappe.db.get_all( - "Subcontracting Inward Order Received Item", - filters={"reference_name": scio_item_name, "docstatus": 1, "is_customer_provided_item": 1}, - pluck="name", - ) self.qty_change = frappe._dict() data = frappe.get_all( "Subcontracting Inward Order Received Item", - {"name": ["in", scio_rm_item_names]}, + {"reference_name": scio_item_name, "docstatus": 1, "is_customer_provided_item": 1}, ["rm_item_code", "required_qty as bom_qty", "work_order_qty", "received_qty"], ) for d in data: wo_item = next( - wo_item for wo_item in self.get("required_items") if wo_item.item_code == d.rm_item_code + ( + wo_item + for wo_item in self.get("required_items") + if wo_item.item_code == d.rm_item_code + ), + None, ) if ( - d.work_order_qty + (wo_item.required_qty if self._action == "submit" else 0) - ) == d.bom_qty and d.received_qty > d.bom_qty: + wo_item + and (d.work_order_qty + (wo_item.required_qty if self._action == "submit" else 0)) + == d.bom_qty + and d.received_qty > d.bom_qty + ): self.qty_change[wo_item.name] = d.received_qty - d.bom_qty def update_subcontracting_inward_order_received_items(self): if scio_item_name := self.get("subcontracting_inward_order_item"): scio_rm_data = frappe.get_all( "Subcontracting Inward Order Received Item", - filters={"reference_name": scio_item_name, "docstatus": 1}, + filters={ + "reference_name": scio_item_name, + "docstatus": 1, + "rm_item_code": ["in", [d.item_code for d in self.get("required_items")]], + }, fields=["name", "rm_item_code"], ) @@ -1328,7 +1411,7 @@ class WorkOrder(Document): frappe.msgprint( _( "Warning: Quantity exceeds maximum producible quantity based on quantity of raw materials received through the Subcontracting Inward Order {0}." - ).format(frappe.bold(self.subcontracting_inward_order)), + ).format(get_link_to_form("Subcontracting Inward Order", self.subcontracting_inward_order)), alert=True, indicator="orange", ) @@ -2179,14 +2262,13 @@ def make_stock_entry( stock_entry.from_bom = 1 stock_entry.bom_no = work_order.bom_no stock_entry.use_multi_level_bom = work_order.use_multi_level_bom + if purpose in ["Material Transfer for Manufacture", "Manufacture"]: + stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order # accept 0 qty as well stock_entry.fg_completed_qty = ( qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty)) ) - if purpose == "Manufacture" and work_order.subcontracting_inward_order: - stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order - if work_order.bom_no: stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required") diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 8c2bf1ae54..6ff0ed9388 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -151,6 +151,17 @@ frappe.ui.form.on("Stock Entry", { if (!check_should_not_attach_bom_items(frm.doc.bom_no)) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } + + if (frm.doc.purpose == "Receive from Customer") { + frm.set_query("against_fg", "items", function () { + return { + query: "erpnext.controllers.subcontracting_inward_controller.get_fg_reference_names", + filters: { + parent: frm.doc.subcontracting_inward_order, + }, + }; + }); + } }, setup_quality_inspection: function (frm) { @@ -935,6 +946,10 @@ frappe.ui.form.on("Stock Entry Detail", { if (item.is_finished_item) { frm.events.set_fg_completed_qty(frm); } + + if (frm.doc.purpose === "Receive from Customer") { + item.t_warehouse = frm.doc.items.find((item) => item.scio_detail).t_warehouse; + } }, set_basic_rate_manually(frm, cdt, cdn) { let row = locals[cdt][cdn]; diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 828710bc8f..26509d804b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -905,10 +905,9 @@ class StockEntry(StockController, SubcontractingInwardController): if d.s_warehouse or d.set_basic_rate_manually: continue - if d.allow_zero_valuation_rate and self.purpose != "Receive from Customer": + if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer": d.basic_rate = 0.0 items.append(d.item_code) - elif d.is_finished_item: if self.purpose == "Manufacture": d.basic_rate = self.get_basic_rate_for_manufactured_item( diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index f9d501c1b9..6ca4ec73f5 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -21,6 +21,7 @@ "is_scrap_item", "quality_inspection", "subcontracted_item", + "against_fg", "section_break_8", "description", "column_break_10", @@ -113,7 +114,8 @@ "label": "Target Warehouse", "oldfieldname": "t_warehouse", "oldfieldtype": "Link", - "options": "Warehouse" + "options": "Warehouse", + "read_only_depends_on": "eval:parent.purpose === \"Receive from Customer\"" }, { "fieldname": "sec_break1", @@ -641,6 +643,16 @@ "print_hide": 1, "read_only": 1, "search_index": 1 + }, + { + "depends_on": "eval:parent.purpose === \"Receive from Customer\" && !doc.scio_detail", + "fieldname": "against_fg", + "fieldtype": "Link", + "label": "Against Finished Good", + "mandatory_depends_on": "eval:parent.purpose === \"Receive from Customer\" && !doc.scio_detail", + "no_copy": 1, + "options": "Subcontracting Inward Order Item", + "set_only_once": 1 } ], "grid_page_length": 50, @@ -648,7 +660,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-14 14:10:38.373099", + "modified": "2025-10-16 11:50:50.573443", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py index 9ff64d1f77..514120e7b6 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -17,6 +17,7 @@ class StockEntryDetail(Document): actual_qty: DF.Float additional_cost: DF.Currency + against_fg: DF.Link | None against_stock_entry: DF.Link | None allow_alternative_item: DF.Check allow_zero_valuation_rate: DF.Check diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js index 358e030784..20481791e3 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js @@ -60,6 +60,14 @@ frappe.ui.form.on("Subcontracting Inward Order", { }; }); + frm.set_query("bom", "items", () => { + return { + filters: { + is_active: 1, + }, + }; + }); + frm.set_query("set_delivery_warehouse", () => { return { filters: { diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py index 0b0ffd6758..ca3abb8bde 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py @@ -275,10 +275,13 @@ class SubcontractingInwardOrder(SubcontractingController): d.precision("qty"), ) for item in self.get("received_items") - if item.reference_name == d.name and item.is_customer_provided_item + if item.reference_name == d.name and item.is_customer_provided_item and item.required_qty ] ) - qty = int(qty) if frappe.get_cached_value("UOM", d.stock_uom, "must_be_whole_number") else qty + qty = min( + int(qty) if frappe.get_cached_value("UOM", d.stock_uom, "must_be_whole_number") else qty, + d.qty - d.produced_qty, + ) item_details.update({"qty": qty, "max_producible_qty": qty}) item_list.append(item_details) diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py index baffce48e8..df590edf27 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py @@ -66,6 +66,7 @@ class IntegrationTestSubcontractingInwardOrder(IntegrationTestCase): "transfer_qty": 5, "uom": "Nos", "conversion_factor": 1, + "against_fg": scio.items[0].name, }, ) rm_in.submit() diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json index 878918d334..11da413fc1 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_item/subcontracting_inward_order_item.json @@ -46,6 +46,7 @@ "in_global_search": 1, "label": "Item Name", "print_hide": 1, + "read_only": 1, "reqd": 1 }, { @@ -185,7 +186,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-14 10:29:29.256455", + "modified": "2025-10-18 18:04:04.204651", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Inward Order Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.json index d4ed145129..02e138e89d 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.json @@ -22,7 +22,9 @@ "column_break_16", "consumed_qty", "work_order_qty", - "returned_qty" + "returned_qty", + "section_break_yhve", + "rate" ], "fields": [ { @@ -32,7 +34,8 @@ "in_list_view": 1, "label": "Item Code", "options": "Item", - "read_only": 1 + "read_only": 1, + "reqd": 1 }, { "columns": 2, @@ -70,7 +73,8 @@ "fieldname": "reference_name", "fieldtype": "Data", "label": "Reference Name", - "read_only": 1 + "read_only": 1, + "reqd": 1 }, { "fieldname": "section_break_13", @@ -116,7 +120,6 @@ }, { "default": "0", - "depends_on": "eval:doc.returned_qty", "fieldname": "returned_qty", "fieldtype": "Float", "label": "Returned Qty", @@ -128,7 +131,7 @@ { "allow_on_submit": 1, "default": "0", - "depends_on": "eval:doc.work_order_qty", + "depends_on": "eval:!(!doc.is_customer_provided_item && doc.is_additional_item)", "fieldname": "work_order_qty", "fieldtype": "Float", "label": "Work Order Qty", @@ -146,7 +149,6 @@ "reqd": 1 }, { - "depends_on": "eval:!doc.is_customer_provided_item", "fieldname": "warehouse", "fieldtype": "Link", "label": "Warehouse", @@ -166,11 +168,27 @@ }, { "default": "0", - "depends_on": "eval:!doc.bom_detail_no", "fieldname": "is_additional_item", "fieldtype": "Check", "label": "Is Additional Item", "read_only": 1 + }, + { + "depends_on": "eval:doc.is_customer_provided_item", + "fieldname": "section_break_yhve", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "rate", + "fieldtype": "Currency", + "label": "Rate", + "mandatory_depends_on": "eval:doc.is_customer_provided_item", + "no_copy": 1, + "non_negative": 1, + "options": "Company:company:default_currency", + "read_only": 1, + "read_only_depends_on": "eval:doc.is_customer_provided_item" } ], "grid_page_length": 50, @@ -178,7 +196,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-14 10:18:58.905093", + "modified": "2025-10-21 23:44:18.302327", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Inward Order Received Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.py index 22994d9e24..52679d1150 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_received_item/subcontracting_inward_order_received_item.py @@ -19,12 +19,13 @@ class SubcontractingInwardOrderReceivedItem(Document): consumed_qty: DF.Float is_additional_item: DF.Check is_customer_provided_item: DF.Check - main_item_code: DF.Link | None + main_item_code: DF.Link parent: DF.Data parentfield: DF.Data parenttype: DF.Data + rate: DF.Currency received_qty: DF.Float - reference_name: DF.Data | None + reference_name: DF.Data required_qty: DF.Float returned_qty: DF.Float rm_item_code: DF.Link -- GitLab