diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index f070c40a589..de11a5ab75f 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -29,7 +29,18 @@ "subcontract", "backflush_raw_materials_of_subcontract_based_on", "column_break_11", +<<<<<<< HEAD "over_transfer_allowance" +======= + "over_transfer_allowance", + "validate_consumed_qty", + "section_break_xcug", + "auto_create_subcontracting_order", + "column_break_izrr", + "auto_create_purchase_receipt", + "request_for_quotation_tab", + "fixed_email" +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) ], "fields": [ { @@ -181,6 +192,71 @@ "fieldtype": "Select", "label": "Update frequency of Project", "options": "Each Transaction\nManual" +<<<<<<< HEAD +======= + }, + { + "default": "0", + "description": "Allows users to submit Purchase Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_purchase_order", + "fieldtype": "Check", + "label": "Allow Purchase Order with Zero Quantity" + }, + { + "default": "0", + "description": "Allows users to submit Request for Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_request_for_quotation", + "fieldtype": "Check", + "label": "Allow Request for Quotation with Zero Quantity" + }, + { + "default": "0", + "description": "Allows users to submit Supplier Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_supplier_quotation", + "fieldtype": "Check", + "label": "Allow Supplier Quotation with Zero Quantity" + }, + { + "fieldname": "section_break_xmlt", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_sbwq", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_fcyl", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "bill_for_rejected_quantity_in_purchase_invoice", + "description": "If enabled, the system will generate an accounting entry for materials rejected in the Purchase Receipt.", + "fieldname": "set_valuation_rate_for_rejected_materials", + "fieldtype": "Check", + "label": "Set Valuation Rate for Rejected Materials" + }, + { + "fieldname": "request_for_quotation_tab", + "fieldtype": "Tab Break", + "label": "Request for Quotation" + }, + { + "description": "If set, the system does not use the user's Email or the standard outgoing Email account for sending request for quotations.", + "fieldname": "fixed_email", + "fieldtype": "Link", + "label": "Fixed Outgoing Email Account", + "link_filters": "[[\"Email Account\",\"enable_outgoing\",\"=\",1]]", + "options": "Email Account" + }, + { + "default": "0", + "depends_on": "eval:doc.backflush_raw_materials_of_subcontract_based_on == \"Material Transferred for Subcontract\"", + "description": "Raw materials consumed qty will be validated based on FG BOM required qty", + "fieldname": "validate_consumed_qty", + "fieldtype": "Check", + "label": "Validate Consumed Qty (as per BOM)" +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) } ], "icon": "fa fa-cog", @@ -188,7 +264,11 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD "modified": "2024-01-12 16:42:01.894346", +======= + "modified": "2025-11-20 12:59:09.925862", +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py index 4680a889d3a..8f5710778b8 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.py +++ b/erpnext/buying/doctype/buying_settings/buying_settings.py @@ -9,6 +9,47 @@ from frappe.model.document import Document class BuyingSettings(Document): +<<<<<<< HEAD +======= + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + allow_multiple_items: DF.Check + allow_zero_qty_in_purchase_order: DF.Check + allow_zero_qty_in_request_for_quotation: DF.Check + allow_zero_qty_in_supplier_quotation: DF.Check + auto_create_purchase_receipt: DF.Check + auto_create_subcontracting_order: DF.Check + backflush_raw_materials_of_subcontract_based_on: DF.Literal[ + "BOM", "Material Transferred for Subcontract" + ] + bill_for_rejected_quantity_in_purchase_invoice: DF.Check + blanket_order_allowance: DF.Float + buying_price_list: DF.Link | None + disable_last_purchase_rate: DF.Check + fixed_email: DF.Link | None + maintain_same_rate: DF.Check + maintain_same_rate_action: DF.Literal["Stop", "Warn"] + over_transfer_allowance: DF.Float + po_required: DF.Literal["No", "Yes"] + pr_required: DF.Literal["No", "Yes"] + project_update_frequency: DF.Literal["Each Transaction", "Manual"] + role_to_override_stop_action: DF.Link | None + set_landed_cost_based_on_purchase_invoice_rate: DF.Check + set_valuation_rate_for_rejected_materials: DF.Check + show_pay_button: DF.Check + supp_master_name: DF.Literal["Supplier Name", "Naming Series", "Auto Name"] + supplier_group: DF.Link | None + use_transaction_date_exchange_rate: DF.Check + validate_consumed_qty: DF.Check + # end: auto-generated types + +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) def validate(self): for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]: frappe.db.set_default(key, self.get(key, "")) diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 6c9f066ff55..509522d800b 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -343,9 +343,23 @@ class SubcontractingController(StockController): i += 1 +<<<<<<< HEAD def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0): doctype = "BOM Explosion Item" if exploded_item else "BOM Item" fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"] +======= + def __remove_serial_and_batch_bundle(self, item): + if item.get("serial_and_batch_bundle"): + frappe.delete_doc("Serial and Batch Bundle", item.serial_and_batch_bundle, force=True) + + def _get_materials_from_bom(self, item_code, bom_no, exploded_item=0): + data = [] + + doctype = "BOM Item" if not exploded_item else "BOM Explosion Item" + fields = [ + {"DIV": [f"`tab{doctype}`.`stock_qty`", "`tabBOM`.`quantity`"], "as": "qty_consumed_per_unit"} + ] +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) alias_dict = { "item_code": "rm_item_code", @@ -371,7 +385,23 @@ class SubcontractingController(StockController): [doctype, "sourced_by_supplier", "=", 0], ] +<<<<<<< HEAD return frappe.get_all("BOM", fields=fields, filters=filters, order_by=f"`tab{doctype}`.`idx`") or [] +======= + data = frappe.get_all("BOM", fields=fields, filters=filters, order_by=f"`tab{doctype}`.`idx`") or [] + to_remove = [] + for item in data: + if item.is_phantom_item: + data += self._get_materials_from_bom( + item.rm_item_code, item.bom_no, exploded_item=exploded_item + ) + to_remove.append(item) + + for item in to_remove: + data.remove(item) + + return data +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) def __update_reserve_warehouse(self, row, item): if self.doctype == self.subcontract_data.order_doctype: @@ -502,8 +532,15 @@ class SubcontractingController(StockController): ): continue +<<<<<<< HEAD if self.doctype == self.subcontract_data.order_doctype or self.backflush_based_on == "BOM": for bom_item in self.__get_materials_from_bom( +======= + if self.doctype == self.subcontract_data.order_doctype or ( + self.backflush_based_on == "BOM" or self.is_return + ): + for bom_item in self._get_materials_from_bom( +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) row.item_code, row.bom, row.get("include_exploded_items") ): qty = flt(bom_item.qty_consumed_per_unit) * flt(row.qty) * row.conversion_factor diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 595620b745b..86a808ecf9d 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -1,6 +1,8 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from collections import defaultdict + import frappe from frappe import _ from frappe.utils import cint, flt, getdate, nowdate @@ -11,6 +13,10 @@ from erpnext.controllers.subcontracting_controller import SubcontractingControll from erpnext.stock.utils import get_incoming_rate +class BOMQuantityError(frappe.ValidationError): + pass + + class SubcontractingReceipt(SubcontractingController): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -94,6 +100,7 @@ class SubcontractingReceipt(SubcontractingController): def on_submit(self): self.validate_available_qty_for_consumption() + self.validate_bom_required_qty() self.update_status_updater_args() self.update_prevdoc_status() self.set_subcontracting_order_status() @@ -298,9 +305,328 @@ class SubcontractingReceipt(SubcontractingController): if self.company: expense_account = self.get_company_default("default_expense_account", ignore_validation=True) +<<<<<<< HEAD for item in self.items: if not item.expense_account: item.expense_account = expense_account +======= + def set_supplied_items_expense_account(self): + for item in self.supplied_items: + if not item.expense_account: + item.expense_account = get_default_expense_account( + frappe._dict( + { + "expense_account": self.get_company_default( + "default_expense_account", ignore_validation=True + ) + } + ), + get_item_defaults(item.rm_item_code, self.company), + get_item_group_defaults(item.rm_item_code, self.company), + get_brand_defaults(item.rm_item_code, self.company), + ) + + def reset_supplied_items(self): + if ( + frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") + == "BOM" + and self.supplied_items + ): + if not any( + item.serial_and_batch_bundle or item.batch_no or item.serial_no + for item in self.supplied_items + ): + self.supplied_items = [] + else: + self.update_rate_for_supplied_items() + + @frappe.whitelist() + def get_scrap_items(self, recalculate_rate=False): + self.remove_scrap_items() + + for item in list(self.items): + if item.bom: + bom = frappe.get_doc("BOM", item.bom) + for scrap_item in bom.scrap_items: + qty = flt(item.qty) * (flt(scrap_item.stock_qty) / flt(bom.quantity)) + rate = ( + get_valuation_rate( + scrap_item.item_code, + self.set_warehouse, + self.doctype, + self.name, + currency=erpnext.get_company_currency(self.company), + company=self.company, + ) + or scrap_item.rate + ) + self.append( + "items", + { + "is_scrap_item": 1, + "reference_name": item.name, + "item_code": scrap_item.item_code, + "item_name": scrap_item.item_name, + "qty": qty, + "stock_uom": scrap_item.stock_uom, + "rate": rate, + "rm_cost_per_qty": 0, + "service_cost_per_qty": 0, + "additional_cost_per_qty": 0, + "scrap_cost_per_qty": 0, + "amount": qty * rate, + "warehouse": self.set_warehouse, + "rejected_warehouse": self.rejected_warehouse, + }, + ) + + if recalculate_rate: + self.calculate_additional_costs() + self.calculate_items_qty_and_amount() + + def remove_scrap_items(self, recalculate_rate=False): + for item in list(self.items): + if item.is_scrap_item: + self.remove(item) + else: + item.scrap_cost_per_qty = 0 + + if recalculate_rate: + self.calculate_items_qty_and_amount() + + @frappe.whitelist() + def set_missing_values(self): + self.set_available_qty_for_consumption() + self.calculate_additional_costs() + self.calculate_items_qty_and_amount() + + def set_available_qty_for_consumption(self): + supplied_items_details = {} + + sco_supplied_item = frappe.qb.DocType("Subcontracting Order Supplied Item") + for item in self.get("items"): + supplied_items = ( + frappe.qb.from_(sco_supplied_item) + .select( + sco_supplied_item.rm_item_code, + sco_supplied_item.reference_name, + (sco_supplied_item.total_supplied_qty - sco_supplied_item.consumed_qty).as_( + "available_qty" + ), + ) + .where( + (sco_supplied_item.parent == item.subcontracting_order) + & (sco_supplied_item.main_item_code == item.item_code) + & (sco_supplied_item.reference_name == item.subcontracting_order_item) + ) + ).run(as_dict=True) + + if supplied_items: + supplied_items_details[item.name] = {} + + for supplied_item in supplied_items: + if supplied_item.rm_item_code not in supplied_items_details[item.name]: + supplied_items_details[item.name][supplied_item.rm_item_code] = 0.0 + + supplied_items_details[item.name][ + supplied_item.rm_item_code + ] += supplied_item.available_qty + else: + for item in self.get("supplied_items"): + item.available_qty_for_consumption = supplied_items_details.get(item.reference_name, {}).get( + item.rm_item_code, 0 + ) + + def calculate_items_qty_and_amount(self): + rm_cost_map = {} + for item in self.get("supplied_items") or []: + item.amount = flt(item.consumed_qty) * flt(item.rate) + + if item.reference_name in rm_cost_map: + rm_cost_map[item.reference_name] += item.amount + else: + rm_cost_map[item.reference_name] = item.amount + + scrap_cost_map = {} + for item in self.get("items") or []: + if item.is_scrap_item: + item.amount = flt(item.qty) * flt(item.rate) + + if item.reference_name in scrap_cost_map: + scrap_cost_map[item.reference_name] += item.amount + else: + scrap_cost_map[item.reference_name] = item.amount + + total_qty = total_amount = 0 + for item in self.get("items") or []: + if not item.is_scrap_item: + if item.qty: + if item.name in rm_cost_map: + item.rm_supp_cost = rm_cost_map[item.name] + item.rm_cost_per_qty = item.rm_supp_cost / item.qty + rm_cost_map.pop(item.name) + + if item.name in scrap_cost_map: + item.scrap_cost_per_qty = scrap_cost_map[item.name] / item.qty + scrap_cost_map.pop(item.name) + else: + item.scrap_cost_per_qty = 0 + + lcv_cost_per_qty = 0.0 + if item.landed_cost_voucher_amount: + lcv_cost_per_qty = item.landed_cost_voucher_amount / item.qty + + item.rate = ( + flt(item.rm_cost_per_qty) + + flt(item.service_cost_per_qty) + + flt(item.additional_cost_per_qty) + + flt(lcv_cost_per_qty) + - flt(item.scrap_cost_per_qty) + ) + + item.received_qty = flt(item.qty) + flt(item.rejected_qty) + item.amount = flt(item.qty) * flt(item.rate) + + total_qty += flt(item.qty) + total_amount += item.amount + else: + self.total_qty = total_qty + self.total = total_amount + + def validate_scrap_items(self): + for item in self.items: + if item.is_scrap_item: + if not item.qty: + frappe.throw( + _("Row #{0}: Scrap Item Qty cannot be zero").format(item.idx), + ) + + if item.rejected_qty: + frappe.throw( + _("Row #{0}: Rejected Qty cannot be set for Scrap Item {1}.").format( + item.idx, frappe.bold(item.item_code) + ), + ) + + if not item.reference_name: + frappe.throw( + _("Row #{0}: Finished Good reference is mandatory for Scrap Item {1}.").format( + item.idx, frappe.bold(item.item_code) + ), + ) + + def validate_accepted_warehouse(self): + for item in self.get("items"): + if flt(item.qty) and not item.warehouse: + if self.set_warehouse: + item.warehouse = self.set_warehouse + else: + frappe.throw( + _("Row #{0}: Accepted Warehouse is mandatory for the accepted Item {1}").format( + item.idx, item.item_code + ) + ) + + if item.get("warehouse") and (item.get("warehouse") == item.get("rejected_warehouse")): + frappe.throw( + _("Row #{0}: Accepted Warehouse and Rejected Warehouse cannot be same").format(item.idx) + ) + + def validate_available_qty_for_consumption(self): + if ( + frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") + == "BOM" + ): + return + + for item in self.get("supplied_items"): + precision = item.precision("consumed_qty") + if ( + item.available_qty_for_consumption + and flt(item.available_qty_for_consumption, precision) - flt(item.consumed_qty, precision) < 0 + ): + msg = _( + """Row {0}: Consumed Qty {1} {2} must be less than or equal to Available Qty For Consumption + {3} {4} in Consumed Items Table.""" + ).format( + item.idx, + flt(item.consumed_qty, precision), + item.stock_uom, + flt(item.available_qty_for_consumption, precision), + item.stock_uom, + ) + + frappe.throw(msg) + + def validate_bom_required_qty(self): + if ( + frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") + == "Material Transferred for Subcontract" + ) and not (frappe.db.get_single_value("Buying Settings", "validate_consumed_qty")): + return + + rm_consumed_dict = self.get_rm_wise_consumed_qty() + + for row in self.items: + precision = row.precision("qty") + for bom_item in self._get_materials_from_bom( + row.item_code, row.bom, row.get("include_exploded_items") + ): + required_qty = flt( + bom_item.qty_consumed_per_unit * row.qty * row.conversion_factor, precision + ) + consumed_qty = rm_consumed_dict.get(bom_item.rm_item_code, 0) + diff = flt(consumed_qty, precision) - flt(required_qty, precision) + + if diff < 0: + msg = _( + """Additional {0} {1} of item {2} required as per BOM to complete this transaction""" + ).format( + frappe.bold(abs(diff)), + frappe.bold(bom_item.stock_uom), + frappe.bold(bom_item.rm_item_code), + ) + + frappe.throw( + msg, + exc=BOMQuantityError, + ) + + def get_rm_wise_consumed_qty(self): + rm_dict = defaultdict(float) + + for row in self.supplied_items: + rm_dict[row.rm_item_code] += row.consumed_qty + + return rm_dict + + def update_status_updater_args(self): + if cint(self.is_return): + self.status_updater.extend( + [ + { + "source_dt": "Subcontracting Receipt Item", + "target_dt": "Subcontracting Order Item", + "join_field": "subcontracting_order_item", + "target_field": "returned_qty", + "source_field": "-1 * qty", + "extra_cond": """ and exists (select name from `tabSubcontracting Receipt` + where name=`tabSubcontracting Receipt Item`.parent and is_return=1)""", + }, + { + "source_dt": "Subcontracting Receipt Item", + "target_dt": "Subcontracting Receipt Item", + "join_field": "subcontracting_receipt_item", + "target_field": "returned_qty", + "target_parent_dt": "Subcontracting Receipt", + "target_parent_field": "per_returned", + "target_ref_field": "received_qty", + "source_field": "-1 * received_qty", + "percent_join_field_parent": "return_against", + }, + ] + ) +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) def update_status(self, status=None, update_modified=False): if not status: diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 1cbab424803..ddff7b40427 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -32,6 +32,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( make_subcontracting_receipt, ) +from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( + BOMQuantityError, +) class TestSubcontractingReceipt(FrappeTestCase): @@ -168,7 +171,7 @@ class TestSubcontractingReceipt(FrappeTestCase): def test_subcontracting_over_receipt(self): """ Behaviour: Raise multiple SCRs against one SCO that in total - receive more than the required qty in the SCO. + receive more than the required qty in the SCO. Expected Result: Error Raised for Over Receipt against SCO. """ from erpnext.controllers.subcontracting_controller import ( @@ -769,6 +772,701 @@ class TestSubcontractingReceipt(FrappeTestCase): scr.cancel() self.assertTrue(scr.docstatus == 2) +<<<<<<< HEAD +======= + def test_subcontract_return_from_rejected_warehouse(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( + make_subcontract_return_against_rejected_warehouse, + ) + + # Create subcontracted item + fg_item = make_item( + "_Test Subcontract Item Return from Rejected Warehouse", + properties={ + "is_stock_item": 1, + "is_sub_contracted_item": 1, + }, + ).name + + # Create service item + service_item = make_item( + "_Test Service Item Return from Rejected Warehouse", properties={"is_stock_item": 0} + ).name + + # Create BOM for the subcontracted item with required raw materials + rm_item1 = make_item( + "_Test RM Item 1 Return from Rejected Warehouse", properties={"is_stock_item": 1} + ).name + + rm_item2 = make_item( + "_Test RM Item 2 Return from Rejected Warehouse", properties={"is_stock_item": 1} + ).name + + make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]) + + # Create warehouses + rejected_warehouse = create_warehouse("_Test Subcontract Rejected Warehouse Return Qty Warehouse") + + # Create service items for subcontracting order + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": service_item, + "qty": 10, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 10, + }, + ] + + # Create Subcontracting Order + sco = get_subcontracting_order(service_items=service_items) + + # Stock raw materials + make_stock_entry(item_code=rm_item1, qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100) + make_stock_entry(item_code=rm_item2, qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100) + + # Transfer raw materials + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + + # Step 1: Create Subcontracting Receipt with rejected quantity + sr = make_subcontracting_receipt(sco.name) + sr.items[0].qty = 8 # Accepted quantity + sr.items[0].rejected_qty = 2 + sr.items[0].rejected_warehouse = rejected_warehouse + sr.save() + sr.submit() + + # Verify initial state + sr.reload() + self.assertEqual(sr.items[0].qty, 8) + self.assertEqual(sr.items[0].rejected_qty, 2) + self.assertEqual(sr.items[0].rejected_warehouse, rejected_warehouse) + + # Step 2: Create Subcontract Return from Rejected Warehouse + sr_return = make_subcontract_return_against_rejected_warehouse(sr.name) + + # Verify the return document properties + self.assertEqual(sr_return.doctype, "Subcontracting Receipt") + self.assertEqual(sr_return.is_return, 1) + self.assertEqual(sr_return.return_against, sr.name) + + # Verify item details in return document + self.assertEqual(len(sr_return.items), 1) + self.assertEqual(sr_return.items[0].item_code, fg_item) + self.assertEqual(sr_return.items[0].warehouse, rejected_warehouse) + self.assertEqual(sr_return.items[0].qty, -2.0) # Negative for return + self.assertEqual(sr_return.items[0].rejected_qty, 0.0) + self.assertEqual(sr_return.items[0].rejected_warehouse, "") + + # Check specific fields that should be set for subcontracting returns + self.assertEqual(sr_return.items[0].subcontracting_order, sco.name) + self.assertEqual(sr_return.items[0].subcontracting_order_item, sr.items[0].subcontracting_order_item) + self.assertEqual(sr_return.items[0].return_qty_from_rejected_warehouse, 1) + + # For returns from rejected warehouse, supplied_items might be empty initially + # They might get populated when the document is saved/submitted + # Or they might not be needed since we're returning finished goods + + # Save and submit the return + sr_return.save() + sr_return.submit() + + # Verify final state + sr_return.reload() + self.assertEqual(sr_return.docstatus, 1) + self.assertEqual(sr_return.status, "Return") + + # Verify stock ledger entries for the return + sle = frappe.get_all( + "Stock Ledger Entry", + filters={ + "voucher_type": "Subcontracting Receipt", + "voucher_no": sr_return.name, + "warehouse": rejected_warehouse, + }, + fields=["item_code", "actual_qty", "warehouse"], + ) + + self.assertEqual(len(sle), 1) + self.assertEqual(sle[0].item_code, fg_item) + self.assertEqual(sle[0].actual_qty, -2.0) # Outward entry from rejected warehouse + self.assertEqual(sle[0].warehouse, rejected_warehouse) + + # Verify that the original document's rejected quantity is not affected + sr.reload() + self.assertEqual(sr.items[0].rejected_qty, 2) # Should remain the same + + @IntegrationTestCase.change_settings("Buying Settings", {"auto_create_purchase_receipt": 1}) + def test_auto_create_purchase_receipt(self): + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + fg_item = "Subcontracted Item SA1" + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 5, + }, + ] + + po = create_purchase_order( + rm_items=service_items, + is_subcontracted=1, + supplier_warehouse="_Test Warehouse 1 - _TC", + do_not_submit=True, + ) + po.append( + "taxes", + { + "account_head": "_Test Account Excise Duty - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "Excise Duty", + "doctype": "Purchase Taxes and Charges", + "rate": 10, + }, + ) + po.save() + po.submit() + + sco = get_subcontracting_order(po_name=po.name) + + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty = 3 + scr.save() + scr.submit() + + pr_details = frappe.get_all( + "Purchase Receipt", + filters={"subcontracting_receipt": scr.name}, + fields=["name", "total_taxes_and_charges"], + ) + + self.assertTrue(pr_details) + + pr_qty = frappe.db.get_value("Purchase Receipt Item", {"parent": pr_details[0]["name"]}, "qty") + self.assertEqual(pr_qty, 6) + + self.assertEqual(pr_details[0]["total_taxes_and_charges"], 60) + + @IntegrationTestCase.change_settings("Buying Settings", {"auto_create_purchase_receipt": 1}) + def test_auto_create_purchase_receipt_with_no_reference_of_po_item(self): + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + fg_item = "Subcontracted Item SA1" + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 5, + }, + ] + + po = create_purchase_order( + rm_items=service_items, + is_subcontracted=1, + supplier_warehouse="_Test Warehouse 1 - _TC", + do_not_submit=True, + ) + po.append( + "taxes", + { + "account_head": "_Test Account Excise Duty - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "Excise Duty", + "doctype": "Purchase Taxes and Charges", + "rate": 10, + }, + ) + po.save() + po.submit() + + sco = get_subcontracting_order(po_name=po.name) + for row in sco.items: + row.db_set("purchase_order_item", None) + + sco.reload() + + for row in sco.items: + self.assertFalse(row.purchase_order_item) + + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + + scr = make_subcontracting_receipt(sco.name) + for row in scr.items: + self.assertFalse(row.purchase_order_item) + + scr.items[0].qty = 3 + scr.save() + scr.submit() + + pr_details = frappe.get_all( + "Purchase Receipt", + filters={"subcontracting_receipt": scr.name}, + fields=["name", "total_taxes_and_charges"], + ) + + self.assertTrue(pr_details) + + pr_qty = frappe.db.get_value("Purchase Receipt Item", {"parent": pr_details[0]["name"]}, "qty") + self.assertEqual(pr_qty, 6) + + self.assertEqual(pr_details[0]["total_taxes_and_charges"], 60) + + def test_use_serial_batch_fields_for_subcontracting_receipt(self): + fg_item = make_item( + "Test Subcontracted Item With Batch No", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BATCH-BNGS-.####", + "is_sub_contracted_item": 1, + }, + ).name + + make_item( + "Test Subcontracted Item With Batch No Service Item 1", + properties={"is_stock_item": 0}, + ) + + make_bom( + item=fg_item, + raw_materials=[ + make_item( + "Test Subcontracted Item With Batch No RM Item 1", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BATCH-RM-BNGS-.####", + }, + ).name + ], + ) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Test Subcontracted Item With Batch No Service Item 1", + "qty": 1, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 1, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + + batch_no = "BATCH-BNGS-0001" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": fg_item, + } + ).insert() + + scr = make_subcontracting_receipt(sco.name) + self.assertFalse(scr.items[0].serial_and_batch_bundle) + scr.items[0].use_serial_batch_fields = 1 + scr.items[0].batch_no = batch_no + + scr.save() + scr.submit() + scr.reload() + self.assertTrue(scr.items[0].serial_and_batch_bundle) + + def test_use_serial_batch_fields_for_subcontracting_receipt_with_rejected_qty(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + fg_item = make_item( + "Test Subcontracted Item With Batch No for Rejected Qty", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BATCH-REJ-BNGS-.####", + "is_sub_contracted_item": 1, + }, + ).name + + make_item( + "Test Subcontracted Item With Batch No Service Item 2", + properties={"is_stock_item": 0}, + ) + + make_bom( + item=fg_item, + raw_materials=[ + make_item( + "Test Subcontracted Item With Batch No RM Item 2", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BATCH-REJ-RM-BNGS-.####", + }, + ).name + ], + ) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Test Subcontracted Item With Batch No Service Item 2", + "qty": 10, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + + batch_no = "BATCH-REJ-BNGS-0001" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": fg_item, + } + ).insert() + + rej_warehouse = create_warehouse("_Test Subcontract Warehouse For Rejected Qty") + + scr = make_subcontracting_receipt(sco.name) + self.assertFalse(scr.items[0].serial_and_batch_bundle) + scr.items[0].use_serial_batch_fields = 1 + scr.items[0].batch_no = batch_no + scr.items[0].received_qty = 10 + scr.items[0].rejected_qty = 2 + scr.items[0].qty = 8 + scr.items[0].rejected_warehouse = rej_warehouse + + scr.save() + scr.submit() + scr.reload() + self.assertTrue(scr.items[0].serial_and_batch_bundle) + self.assertTrue(scr.items[0].rejected_serial_and_batch_bundle) + + def test_subcontracting_receipt_for_batch_materials_without_use_serial_batch_fields(self): + from erpnext.controllers.subcontracting_controller import make_rm_stock_entry + + set_backflush_based_on("Material Transferred for Subcontract") + + fg_item = make_item( + "Test Subcontracted FG Item With Batch No and Without Use Serial Batch Fields", + properties={"is_stock_item": 1, "is_sub_contracted_item": 1}, + ).name + + rm_item1 = make_item( + "Test Subcontracted RM Item With Batch No and Without Use Serial Batch Fields", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BATCH-RM-BNGS-.####", + }, + ).name + + make_item( + "Subcontracted Service Item 21", + properties={ + "is_stock_item": 0, + }, + ) + + bom = make_bom(item=fg_item, raw_materials=[rm_item1]) + + rm_batch_no = None + for row in bom.items: + se = make_stock_entry( + item_code=row.item_code, + qty=10, + target="_Test Warehouse - _TC", + rate=300, + ) + + se.reload() + rm_batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 21", + "qty": 10, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + self.assertTrue(sco.docstatus) + rm_items = [ + { + "name": sco.supplied_items[0].name, + "item_code": rm_item1, + "rm_item_code": rm_item1, + "item_name": rm_item1, + "qty": 10, + "warehouse": "_Test Warehouse - _TC", + "rate": 100, + "stock_uom": frappe.get_cached_value("Item", rm_item1, "stock_uom"), + "use_serial_batch_fields": 1, + }, + ] + se = frappe.get_doc(make_rm_stock_entry(sco.name, rm_items)) + se.items[0].subcontracted_item = fg_item + se.items[0].s_warehouse = "_Test Warehouse - _TC" + se.items[0].t_warehouse = "_Test Warehouse 1 - _TC" + se.items[0].use_serial_batch_fields = 1 + se.items[0].batch_no = rm_batch_no + se.submit() + + self.assertEqual(se.items[0].batch_no, rm_batch_no) + self.assertEqual(se.items[0].use_serial_batch_fields, 1) + + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty = 2 + scr.save() + scr.submit() + + self.assertEqual(scr.supplied_items[0].consumed_qty, 2) + self.assertEqual(scr.supplied_items[0].batch_no, rm_batch_no) + self.assertEqual(get_batch_from_bundle(scr.supplied_items[0].serial_and_batch_bundle), rm_batch_no) + + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty = 2 + scr.save() + scr.submit() + + self.assertEqual(scr.supplied_items[0].consumed_qty, 2) + self.assertEqual(scr.supplied_items[0].batch_no, rm_batch_no) + self.assertEqual(get_batch_from_bundle(scr.supplied_items[0].serial_and_batch_bundle), rm_batch_no) + + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty = 6 + scr.save() + scr.submit() + + self.assertEqual(scr.supplied_items[0].consumed_qty, 6) + self.assertEqual(scr.supplied_items[0].batch_no, rm_batch_no) + self.assertEqual(get_batch_from_bundle(scr.supplied_items[0].serial_and_batch_bundle), rm_batch_no) + + sco.reload() + self.assertEqual(sco.status, "Completed") + + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) + + def test_change_batch_for_raw_materials(self): + set_backflush_based_on("BOM") + + fg_item = make_item(properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name + rm_item1 = make_item( + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BNGS-.####", + } + ).name + + bom = make_bom(item=fg_item, raw_materials=[rm_item1]) + second_batch_no = None + for row in bom.items: + se = make_stock_entry( + item_code=row.item_code, + qty=1, + target="_Test Warehouse 1 - _TC", + rate=300, + ) + + se.reload() + se1 = make_stock_entry( + item_code=row.item_code, + qty=1, + target="_Test Warehouse 1 - _TC", + rate=300, + ) + + se1.reload() + + second_batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 1, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 1, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + scr = make_subcontracting_receipt(sco.name) + scr.save() + scr.reload() + + scr.supplied_items[0].batch_no = second_batch_no + scr.supplied_items[0].use_serial_batch_fields = 1 + scr.submit() + scr.reload() + + batch_no = get_batch_from_bundle(scr.supplied_items[0].serial_and_batch_bundle) + self.assertEqual(batch_no, second_batch_no) + self.assertEqual(scr.items[0].rm_cost_per_qty, 300) + self.assertEqual(scr.items[0].service_cost_per_qty, 100) + + def test_bom_required_qty_validation_based_on_bom(self): + set_backflush_based_on("BOM") + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) + + fg_item = make_item(properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name + rm_item1 = make_item( + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BRQV-.####", + } + ).name + + make_bom(item=fg_item, raw_materials=[rm_item1], rm_qty=2) + se = make_stock_entry( + item_code=rm_item1, + qty=1, + target="_Test Warehouse 1 - _TC", + rate=300, + ) + + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 1, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 1, + }, + ] + + sco = get_subcontracting_order(service_items=service_items) + scr = make_subcontracting_receipt(sco.name) + scr.save() + scr.reload() + + self.assertEqual(scr.supplied_items[0].batch_no, batch_no) + self.assertEqual(scr.supplied_items[0].consumed_qty, 1) + self.assertEqual(scr.supplied_items[0].required_qty, 2) + + self.assertRaises(BOMQuantityError, scr.submit) + + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) + + def test_bom_required_qty_validation_based_on_transfer(self): + from erpnext.controllers.subcontracting_controller import ( + make_rm_stock_entry as make_subcontract_transfer_entry, + ) + + set_backflush_based_on("Material Transferred for Subcontract") + frappe.db.set_single_value("Buying Settings", "validate_consumed_qty", 1) + + item_code = "_Test Subcontracted Validation FG Item 1" + rm_item1 = make_item( + properties={ + "is_stock_item": 1, + } + ).name + + make_subcontracted_item(item_code=item_code, raw_materials=[rm_item1]) + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 100, + "fg_item": item_code, + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order( + service_items=service_items, + include_exploded_items=0, + ) + + # inward raw material stock + make_stock_entry(target="_Test Warehouse - _TC", item_code=rm_item1, qty=10, basic_rate=100) + + rm_items = [ + { + "item_code": item_code, + "rm_item_code": sco.supplied_items[0].rm_item_code, + "qty": sco.supplied_items[0].required_qty - 5, + "warehouse": "_Test Warehouse - _TC", + "stock_uom": "Nos", + }, + ] + + # transfer partial raw materials + ste = frappe.get_doc(make_subcontract_transfer_entry(sco.name, rm_items)) + ste.to_warehouse = "_Test Warehouse 1 - _TC" + ste.save() + ste.submit() + + scr = make_subcontracting_receipt(sco.name) + scr.save() + + self.assertRaises(BOMQuantityError, scr.submit) + +>>>>>>> d01c4b68fe (fix: add validation for FG Items as per BOM qty (#50579)) def make_return_subcontracting_receipt(**args): args = frappe._dict(args)