feat: co product by product support (#52979)

This commit is contained in:
Mihir Kandoi
2026-03-11 14:46:36 +05:30
committed by GitHub
parent 65c33e6b39
commit f1ac0376fb
53 changed files with 1490 additions and 701 deletions

View File

@@ -1435,7 +1435,7 @@ class StockController(AccountsController):
elif self.doctype == "Stock Entry" and row.t_warehouse:
qi_required = True # inward stock needs inspection
if row.get("is_scrap_item"):
if row.get("type") or row.get("is_legacy_scrap_item"):
continue
if qi_required: # validate row only if inspection is required on item level

View File

@@ -160,7 +160,7 @@ class SubcontractingController(StockController):
).format(item.idx, get_link_to_form("Item", item.item_code))
)
if not item.get("is_scrap_item"):
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not is_sub_contracted_item:
frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
@@ -206,7 +206,7 @@ class SubcontractingController(StockController):
).format(item.idx, item.item_name)
)
if self.doctype != "Subcontracting Inward Order":
if self.doctype not in ["Subcontracting Inward Order", "Subcontracting Receipt"]:
item.amount = item.qty * item.rate
if item.bom:
@@ -238,7 +238,7 @@ class SubcontractingController(StockController):
and self._doc_before_save
):
for row in self._doc_before_save.get("items"):
item_dict[row.name] = (row.item_code, row.qty + (row.get("rejected_qty") or 0))
item_dict[row.name] = (row.item_code, row.received_qty)
return item_dict
@@ -264,7 +264,7 @@ class SubcontractingController(StockController):
self.__reference_name.append(row.name)
if (row.name not in item_dict) or (
row.item_code,
row.qty + (row.get("rejected_qty") or 0),
row.received_qty,
) != item_dict[row.name]:
self.__changed_name.append(row.name)
@@ -961,7 +961,7 @@ class SubcontractingController(StockController):
):
qty = (
flt(bom_item.qty_consumed_per_unit)
* flt(row.qty + (row.get("rejected_qty") or 0))
* flt(row.get("received_qty") or (row.qty + (row.get("rejected_qty") or 0)))
* row.conversion_factor
)
bom_item.main_item_code = row.item_code
@@ -1278,22 +1278,28 @@ class SubcontractingController(StockController):
if self.total_additional_costs:
if self.distribute_additional_costs_based_on == "Amount":
total_amt = sum(
flt(item.amount) for item in self.get("items") if not item.get("is_scrap_item")
flt(item.amount)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
)
for item in self.items:
if not item.get("is_scrap_item"):
if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt
) / item.qty
else:
total_qty = sum(flt(item.qty) for item in self.get("items") if not item.get("is_scrap_item"))
total_qty = sum(
flt(item.qty)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
)
additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items:
if not item.get("is_scrap_item"):
if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = additional_cost_per_qty
else:
for item in self.items:
if not item.get("is_scrap_item"):
if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = 0
@frappe.whitelist()

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
import frappe
from frappe import _, bold
from frappe.query_builder import Case
@@ -18,7 +20,7 @@ class SubcontractingInwardController:
def on_submit_subcontracting_inward(self):
self.update_inward_order_item()
self.update_inward_order_received_items()
self.update_inward_order_scrap_items()
self.update_inward_order_secondary_items()
self.create_stock_reservation_entries_for_inward()
self.update_inward_order_status()
@@ -28,7 +30,7 @@ class SubcontractingInwardController:
self.validate_delivery()
self.validate_receive_from_customer_cancel()
self.update_inward_order_received_items()
self.update_inward_order_scrap_items()
self.update_inward_order_secondary_items()
self.remove_reference_for_additional_items()
self.update_inward_order_status()
@@ -239,7 +241,8 @@ class SubcontractingInwardController:
item
for item in self.get("items")
if not item.is_finished_item
and not item.is_scrap_item
and not item.type
and not item.is_legacy_scrap_item
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
]
@@ -368,7 +371,9 @@ class SubcontractingInwardController:
if self.subcontracting_inward_order:
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:
if (
item.is_finished_item or item.type or item.is_legacy_scrap_item
) and item.valuation_rate == 0:
item.allow_zero_valuation_rate = 1
def validate_warehouse_(self):
@@ -467,7 +472,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save()
else:
for item in self.items:
if not item.is_scrap_item:
if not item.type and not item.is_legacy_scrap_item:
delivered_qty, returned_qty = frappe.get_value(
"Subcontracting Inward Order Item",
item.scio_detail,
@@ -519,7 +524,7 @@ class SubcontractingInwardController:
if max_allowed_qty:
max_allowed_qty = max_allowed_qty[0]
else:
table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item")
table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
query = (
frappe.qb.from_(table)
.select((table.produced_qty - table.delivered_qty).as_("max_allowed_qty"))
@@ -538,8 +543,8 @@ class SubcontractingInwardController:
bold(
frappe.get_cached_value(
"Subcontracting Inward Order Item"
if not item.is_scrap_item
else "Subcontracting Inward Order Scrap Item",
if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item",
item.scio_detail,
"stock_uom",
)
@@ -590,9 +595,9 @@ class SubcontractingInwardController:
)
for item in [item for item in self.items if not item.is_finished_item]:
if item.is_scrap_item:
scio_scrap_item = frappe.get_value(
"Subcontracting Inward Order Scrap Item",
if item.type or item.is_legacy_scrap_item:
scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Secondary Item",
{
"docstatus": 1,
"item_code": item.item_code,
@@ -603,12 +608,13 @@ class SubcontractingInwardController:
as_dict=True,
)
if (
scio_scrap_item
and scio_scrap_item.delivered_qty > scio_scrap_item.produced_qty - item.transfer_qty
scio_secondary_item
and scio_secondary_item.delivered_qty
> scio_secondary_item.produced_qty - item.transfer_qty
):
frappe.throw(
_(
"Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Scrap Item {1} produced cannot be less than quantity delivered."
"Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Secondary Item {1} produced cannot be less than quantity delivered."
).format(item.idx, get_link_to_form("Item", item.item_code))
)
else:
@@ -648,8 +654,8 @@ class SubcontractingInwardController:
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.is_scrap_item
else "Subcontracting Inward Order Scrap Item"
if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item"
)
frappe.db.set_value(
doctype,
@@ -763,7 +769,11 @@ class SubcontractingInwardController:
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]
items = [
item
for item in self.items
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item
]
item_code_wh = frappe._dict(
{
(
@@ -860,24 +870,24 @@ class SubcontractingInwardController:
doc.insert()
doc.submit()
def update_inward_order_scrap_items(self):
def update_inward_order_secondary_items(self):
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
scrap_items_list = [item for item in self.items if item.is_scrap_item]
scrap_items = frappe._dict(
{
(item.item_code, item.t_warehouse): item.transfer_qty
if self._action == "submit"
else -item.transfer_qty
for item in scrap_items_list
}
)
if scrap_items:
item_codes, warehouses = zip(*list(scrap_items.keys()), strict=True)
secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item]
secondary_items = defaultdict(float)
for item in secondary_items_list:
secondary_items[(item.item_code, item.t_warehouse)] += (
item.transfer_qty if self._action == "submit" else -item.transfer_qty
)
secondary_items = frappe._dict(secondary_items)
if secondary_items:
item_codes, warehouses = zip(*list(secondary_items.keys()), strict=True)
item_codes = list(item_codes)
warehouses = list(warehouses)
result = frappe.get_all(
"Subcontracting Inward Order Scrap Item",
"Subcontracting Inward Order Secondary Item",
filters={
"item_code": ["in", item_codes],
"warehouse": ["in", warehouses],
@@ -890,7 +900,7 @@ class SubcontractingInwardController:
)
if result:
scrap_item_dict = frappe._dict(
secondary_items_dict = frappe._dict(
{
(d.item_code, d.warehouse): frappe._dict(
{"name": d.name, "produced_qty": d.produced_qty}
@@ -900,40 +910,45 @@ class SubcontractingInwardController:
)
deleted_docs = []
case_expr = Case()
table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item")
for key, value in scrap_item_dict.items():
if self._action == "cancel" and value.produced_qty - abs(scrap_items.get(key)) == 0:
table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
for key, value in secondary_items_dict.items():
if (
self._action == "cancel"
and value.produced_qty - abs(secondary_items.get(key)) == 0
):
deleted_docs.append(value.name)
frappe.delete_doc("Subcontracting Inward Order Scrap Item", value.name)
frappe.delete_doc("Subcontracting Inward Order Secondary Item", value.name)
else:
case_expr = case_expr.when(
table.name == value.name, value.produced_qty + scrap_items.get(key)
table.name == value.name, value.produced_qty + secondary_items.get(key)
)
if final_list := list(
set([v.name for v in scrap_item_dict.values()]) - set(deleted_docs)
set([v.name for v in secondary_items_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()
fg_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code
for scrap_item in [
for secondary_item in [
item
for item in scrap_items_list
for item in secondary_items_list
if (item.item_code, item.t_warehouse) not in [(d.item_code, d.warehouse) for d in result]
]:
doc = frappe.new_doc(
"Subcontracting Inward Order Scrap Item",
"Subcontracting Inward Order Secondary Item",
parent=scio,
parenttype="Subcontracting Inward Order",
parentfield="scrap_items",
idx=frappe.db.count("Subcontracting Inward Order Scrap Item", {"parent": scio}) + 1,
item_code=scrap_item.item_code,
parentfield="secondary_items",
idx=frappe.db.count("Subcontracting Inward Order Secondary Item", {"parent": scio})
+ 1,
item_code=secondary_item.item_code,
fg_item_code=fg_item_code,
stock_uom=scrap_item.stock_uom,
warehouse=scrap_item.t_warehouse,
produced_qty=scrap_item.transfer_qty,
stock_uom=secondary_item.stock_uom,
warehouse=secondary_item.t_warehouse,
produced_qty=secondary_item.transfer_qty,
type=secondary_item.type,
delivered_qty=0,
reference_name=frappe.get_value(
"Work Order", self.work_order, "subcontracting_inward_order_item"
@@ -965,7 +980,7 @@ class SubcontractingInwardController:
and (
not frappe.db.exists("Subcontracting Inward Order Received Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Scrap Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Secondary Item", item.scio_detail)
)
]
for item in items:

View File

@@ -6,7 +6,7 @@ from collections import defaultdict
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import cint
from frappe.utils import cint, flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.controllers.subcontracting_controller import (
@@ -500,8 +500,8 @@ class TestSubcontractingController(IntegrationTestCase):
scr1.items[0].qty = 2
add_second_row_in_scr(scr1)
scr1.flags.ignore_mandatory = True
scr1.save()
scr1.set_missing_values()
scr1.save()
scr1.submit()
for _key, value in get_supplied_items(scr1).items():
@@ -512,8 +512,8 @@ class TestSubcontractingController(IntegrationTestCase):
scr2.items[0].qty = 2
add_second_row_in_scr(scr2)
scr2.flags.ignore_mandatory = True
scr2.save()
scr2.set_missing_values()
scr2.save()
scr2.submit()
for _key, value in get_supplied_items(scr2).items():
@@ -522,8 +522,8 @@ class TestSubcontractingController(IntegrationTestCase):
scr3 = make_subcontracting_receipt(sco.name)
scr3.items[0].qty = 2
scr3.flags.ignore_mandatory = True
scr3.save()
scr3.set_missing_values()
scr3.save()
scr3.submit()
for _key, value in get_supplied_items(scr3).items():
@@ -1162,6 +1162,54 @@ class TestSubcontractingController(IntegrationTestCase):
self.assertEqual([item.rm_item_code for item in sco.supplied_items], expected)
def test_co_by_product(self):
frappe.set_value("UOM", "Nos", "must_be_whole_number", 0)
fg_item = make_item("FG Item", properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name
rm_item = make_item("RM Item", properties={"is_stock_item": 1}).name
scrap_item = make_item("Scrap Item", properties={"is_stock_item": 1}).name
make_bom(
item=fg_item, raw_materials=[rm_item], scrap_items=[scrap_item], process_loss_percentage=10
).name
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 11",
"qty": 5,
"rate": 100,
"fg_item": fg_item,
"fg_item_qty": 5,
},
]
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),
)
scr1 = make_subcontracting_receipt(sco.name)
scr1.get_secondary_items()
scr1.save()
self.assertEqual(scr1.items[0].received_qty, 5)
self.assertEqual(scr1.items[0].process_loss_qty, 0.5)
self.assertEqual(scr1.items[0].qty, 4.5)
self.assertEqual(scr1.items[0].rate, 200)
self.assertEqual(scr1.items[0].amount, 900)
self.assertEqual(scr1.items[1].item_code, scrap_item)
self.assertEqual(scr1.items[1].received_qty, 5)
self.assertEqual(scr1.items[1].process_loss_qty, 0.5)
self.assertEqual(scr1.items[1].qty, 4.5)
self.assertEqual(flt(scr1.items[1].rate, 3), 11.111)
self.assertEqual(scr1.items[1].amount, 50)
frappe.set_value("UOM", "Nos", "must_be_whole_number", 1)
def add_second_row_in_scr(scr):
item_dict = {}