fix: add validation for FG Items as per BOM qty (#50579)

Co-authored-by: Kavin <78342682+kavin0411@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit d01c4b68fe)

# Conflicts:
#	erpnext/buying/doctype/buying_settings/buying_settings.json
#	erpnext/buying/doctype/buying_settings/buying_settings.py
#	erpnext/controllers/subcontracting_controller.py
#	erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
#	erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
This commit is contained in:
Kavin
2025-11-24 11:47:14 +05:30
committed by Mergify
parent e6f505ef81
commit d81c724eac
5 changed files with 1183 additions and 1 deletions

View File

@@ -29,7 +29,18 @@
"subcontract", "subcontract",
"backflush_raw_materials_of_subcontract_based_on", "backflush_raw_materials_of_subcontract_based_on",
"column_break_11", "column_break_11",
<<<<<<< HEAD
"over_transfer_allowance" "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": [ "fields": [
{ {
@@ -181,6 +192,71 @@
"fieldtype": "Select", "fieldtype": "Select",
"label": "Update frequency of Project", "label": "Update frequency of Project",
"options": "Each Transaction\nManual" "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", "icon": "fa fa-cog",
@@ -188,7 +264,11 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
<<<<<<< HEAD
"modified": "2024-01-12 16:42:01.894346", "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", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Buying Settings", "name": "Buying Settings",

View File

@@ -9,6 +9,47 @@ from frappe.model.document import Document
class BuyingSettings(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): def validate(self):
for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]: for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]:
frappe.db.set_default(key, self.get(key, "")) frappe.db.set_default(key, self.get(key, ""))

View File

@@ -343,9 +343,23 @@ class SubcontractingController(StockController):
i += 1 i += 1
<<<<<<< HEAD
def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0): def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0):
doctype = "BOM Explosion Item" if exploded_item else "BOM Item" doctype = "BOM Explosion Item" if exploded_item else "BOM Item"
fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"] 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 = { alias_dict = {
"item_code": "rm_item_code", "item_code": "rm_item_code",
@@ -371,7 +385,23 @@ class SubcontractingController(StockController):
[doctype, "sourced_by_supplier", "=", 0], [doctype, "sourced_by_supplier", "=", 0],
] ]
<<<<<<< HEAD
return frappe.get_all("BOM", fields=fields, filters=filters, order_by=f"`tab{doctype}`.`idx`") or [] 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): def __update_reserve_warehouse(self, row, item):
if self.doctype == self.subcontract_data.order_doctype: if self.doctype == self.subcontract_data.order_doctype:
@@ -502,8 +532,15 @@ class SubcontractingController(StockController):
): ):
continue continue
<<<<<<< HEAD
if self.doctype == self.subcontract_data.order_doctype or self.backflush_based_on == "BOM": if self.doctype == self.subcontract_data.order_doctype or self.backflush_based_on == "BOM":
for bom_item in self.__get_materials_from_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") row.item_code, row.bom, row.get("include_exploded_items")
): ):
qty = flt(bom_item.qty_consumed_per_unit) * flt(row.qty) * row.conversion_factor qty = flt(bom_item.qty_consumed_per_unit) * flt(row.qty) * row.conversion_factor

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
from collections import defaultdict
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import cint, flt, getdate, nowdate 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 from erpnext.stock.utils import get_incoming_rate
class BOMQuantityError(frappe.ValidationError):
pass
class SubcontractingReceipt(SubcontractingController): class SubcontractingReceipt(SubcontractingController):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -94,6 +100,7 @@ class SubcontractingReceipt(SubcontractingController):
def on_submit(self): def on_submit(self):
self.validate_available_qty_for_consumption() self.validate_available_qty_for_consumption()
self.validate_bom_required_qty()
self.update_status_updater_args() self.update_status_updater_args()
self.update_prevdoc_status() self.update_prevdoc_status()
self.set_subcontracting_order_status() self.set_subcontracting_order_status()
@@ -298,9 +305,328 @@ class SubcontractingReceipt(SubcontractingController):
if self.company: if self.company:
expense_account = self.get_company_default("default_expense_account", ignore_validation=True) expense_account = self.get_company_default("default_expense_account", ignore_validation=True)
<<<<<<< HEAD
for item in self.items: for item in self.items:
if not item.expense_account: if not item.expense_account:
item.expense_account = 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): def update_status(self, status=None, update_modified=False):
if not status: if not status:

View File

@@ -32,6 +32,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
make_subcontracting_receipt, make_subcontracting_receipt,
) )
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
BOMQuantityError,
)
class TestSubcontractingReceipt(FrappeTestCase): class TestSubcontractingReceipt(FrappeTestCase):
@@ -168,7 +171,7 @@ class TestSubcontractingReceipt(FrappeTestCase):
def test_subcontracting_over_receipt(self): def test_subcontracting_over_receipt(self):
""" """
Behaviour: Raise multiple SCRs against one SCO that in total 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. Expected Result: Error Raised for Over Receipt against SCO.
""" """
from erpnext.controllers.subcontracting_controller import ( from erpnext.controllers.subcontracting_controller import (
@@ -769,6 +772,701 @@ class TestSubcontractingReceipt(FrappeTestCase):
scr.cancel() scr.cancel()
self.assertTrue(scr.docstatus == 2) 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): def make_return_subcontracting_receipt(**args):
args = frappe._dict(args) args = frappe._dict(args)