feat: subcontracting inward (#47728)

* feat: subcontracting inward

* feat: stock reservation

* feat: subcontracting delivery

* feat: all remaining stuff

* fix: linter errors

* fix: patch

* fix: modify stock entry type validation

* fix: customer provided item cost field mandatory validation

* fix: failing tests

* fix: failing tests

* fix: subcontracting controlller

* refactor: semi final

* refactor: final

* chore: resolve conflicts

* refactor: changes requested

* fix: reservation transfer of extra qty

* fix: consider add cost for customer provided rate field

* test: create test data

* test: subcontracted sales order (partial)

* test: fin

* fix: do not add self RM in DN created from SI

* fix: failing test case

* fix: conflicting function name

* refactor: final changes

* fix: more bugs

* perf: various and major performance improvements

* fix: consider warehouse as well in all queries

* fix: same item code with diff warehouse in manufacture entry

* refactor: readability

* fix: frontend validations

* perf: replace query inside loop with single query

* fix: set additional item flag to true when extra customer provided item is received

* fix: bugs found by coderabbit

* fix: more coderabbit bugs

* fix: add validation to disallow cancellation of manufacturing entry

* perf: use cached values wherever it makes sense

* test: fix redundant insert to child tables

* fix: consider SI return of billed self RM

* fix: bug found by coderabbit

---------

Co-authored-by: Mihir Kandoi <mihirkandoi@Mihirs-MacBook-Air.local>
This commit is contained in:
Mihir Kandoi
2025-10-14 15:00:49 +05:30
committed by GitHub
parent 9772ca75c4
commit f2b948a483
76 changed files with 4970 additions and 229 deletions

View File

@@ -35,6 +35,14 @@ class SubcontractingController(StockController):
"order_supplied_items_field": "Purchase Order Item Supplied",
}
)
elif self.doctype == "Subcontracting Inward Order":
self.subcontract_data = frappe._dict(
{
"order_doctype": "Subcontracting Inward Order",
"order_field": "subcontracting_inward_order",
"rm_detail_field": "scio_detail",
}
)
else:
self.subcontract_data = frappe._dict(
{
@@ -47,14 +55,22 @@ class SubcontractingController(StockController):
)
def before_validate(self):
if self.doctype in ["Subcontracting Order", "Subcontracting Receipt"]:
if self.doctype in [
"Subcontracting Order",
"Subcontracting Inward Order",
"Subcontracting Receipt",
]:
self.remove_empty_rows()
self.set_items_conversion_factor()
def validate(self):
if self.doctype in ["Subcontracting Order", "Subcontracting Receipt"]:
if self.doctype in ["Subcontracting Order", "Subcontracting Receipt", "Subcontracting Inward Order"]:
self.validate_items()
self.create_raw_materials_supplied()
self.create_raw_materials_supplied_or_received(
raw_material_table="supplied_items"
if self.doctype != "Subcontracting Inward Order"
else "received_items"
)
self.set_valuation_rate_for_rm()
else:
super().validate()
@@ -109,7 +125,7 @@ class SubcontractingController(StockController):
)
def remove_empty_rows(self):
for key in ["service_items", "items", "supplied_items"]:
for key in ["service_items", "items", "supplied_items", "received_items"]:
if self.get(key):
idx = 1
for item in self.get(key)[:]:
@@ -133,33 +149,47 @@ class SubcontractingController(StockController):
if not is_stock_item:
frappe.throw(_("Row {0}: Item {1} must be a stock item.").format(item.idx, item.item_name))
if (
self.doctype == "Subcontracting Inward Order"
and item.delivery_warehouse == self.customer_warehouse
):
frappe.throw(
_(
"Row {0}: Delivery Warehouse cannot be same as Customer Warehouse for Item {1}."
).format(item.idx, frappe.bold(item.item_name))
)
if not item.get("is_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)
)
if (
self.doctype == "Subcontracting Order" and not item.subcontracting_conversion_factor
): # this condition will only be true if user has recently updated from develop branch
service_item_qty = frappe.get_value(
"Subcontracting Order Service Item",
filters={"purchase_order_item": item.purchase_order_item, "parent": self.name},
fieldname=["qty"],
if self.doctype != "Subcontracting Receipt" and item.qty > flt(
get_pending_subcontracted_quantity(
self.doctype,
self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order,
).get(
item.purchase_order_item
if self.doctype == "Subcontracting Order"
else item.sales_order_item
)
item.subcontracting_conversion_factor = service_item_qty / item.qty
if self.doctype not in "Subcontracting Receipt" and item.qty > flt(
get_pending_subcontracted_quantity(self.purchase_order).get(item.purchase_order_item)
/ item.subcontracting_conversion_factor,
frappe.get_precision("Purchase Order Item", "qty"),
frappe.get_precision(
"Purchase Order Item"
if self.doctype == "Subcontracting Order"
else "Sales Order Item",
"qty",
),
):
frappe.throw(
_(
"Row {0}: Item {1}'s quantity cannot be higher than the available quantity."
).format(item.idx, item.item_name)
)
item.amount = item.qty * item.rate
if self.doctype != "Subcontracting Inward Order":
item.amount = item.qty * item.rate
if item.bom:
is_active, bom_item = frappe.get_value("BOM", item.bom, ["is_active", "item"])
@@ -198,7 +228,10 @@ class SubcontractingController(StockController):
self.__changed_name = []
self.__reference_name = []
if self.doctype in ["Purchase Order", "Subcontracting Order"] or self.is_new():
if (
self.doctype in ["Purchase Order", "Subcontracting Order", "Subcontracting Inward Order"]
or self.is_new()
):
self.set(self.raw_material_table, [])
return
@@ -220,8 +253,13 @@ class SubcontractingController(StockController):
self.__changed_name.extend(item_dict.keys())
def __get_backflush_based_on(self):
self.backflush_based_on = frappe.db.get_single_value(
"Buying Settings", "backflush_raw_materials_of_subcontract_based_on"
self.backflush_based_on = (
frappe.db.get_single_value(
"Buying Settings",
"backflush_raw_materials_of_subcontract_based_on",
)
if self.subcontract_data.order_doctype == "Subcontracting Order"
else "Material Transferred for Subcontract"
)
def initialized_fields(self):
@@ -233,7 +271,7 @@ class SubcontractingController(StockController):
def __get_subcontract_orders(self):
self.subcontract_orders = []
if self.doctype in ["Purchase Order", "Subcontracting Order"]:
if self.doctype in ["Purchase Order", "Subcontracting Order", "Subcontracting Inward Order"]:
return
self.subcontract_orders = [
@@ -543,8 +581,13 @@ class SubcontractingController(StockController):
return frappe.get_all("BOM", fields=fields, filters=filters, order_by=f"`tab{doctype}`.`idx`") or []
def __update_reserve_warehouse(self, row, item):
if self.doctype == self.subcontract_data.order_doctype:
if (
self.doctype == self.subcontract_data.order_doctype
and self.doctype != "Subcontracting Inward Order"
):
row.reserve_warehouse = self.set_reserve_warehouse or item.warehouse
elif frappe.get_cached_value("Item", row.rm_item_code, "is_customer_provided_item"):
row.warehouse = self.customer_warehouse
def __set_alternative_item(self, bom_item):
if self.alternative_item_details.get(bom_item.rm_item_code):
@@ -619,7 +662,7 @@ class SubcontractingController(StockController):
return serial_nos
def __add_supplied_item(self, item_row, bom_item, qty):
def __add_supplied_or_received_item(self, item_row, bom_item, qty):
bom_item.conversion_factor = item_row.conversion_factor
rm_obj = self.append(self.raw_material_table, bom_item)
if rm_obj.get("qty"):
@@ -632,7 +675,8 @@ class SubcontractingController(StockController):
if self.doctype == self.subcontract_data.order_doctype:
rm_obj.required_qty = flt(qty, rm_obj.precision("required_qty"))
rm_obj.amount = flt(rm_obj.required_qty * rm_obj.rate, rm_obj.precision("amount"))
if self.doctype != "Subcontracting Inward Order":
rm_obj.amount = flt(rm_obj.required_qty * rm_obj.rate, rm_obj.precision("amount"))
else:
rm_obj.consumed_qty = flt(qty, rm_obj.precision("consumed_qty"))
rm_obj.required_qty = flt(bom_item.required_qty or qty, rm_obj.precision("required_qty"))
@@ -841,14 +885,14 @@ class SubcontractingController(StockController):
return qty
def __set_supplied_items(self):
def __set_supplied_or_received_items(self):
self.bom_items = {}
has_supplied_items = True if self.get(self.raw_material_table) else False
has_items = True if self.get(self.raw_material_table) else False
for row in self.items:
if self.doctype != self.subcontract_data.order_doctype and (
(self.__changed_name and row.name not in self.__changed_name)
or (has_supplied_items and not self.__changed_name)
or (has_items and not self.__changed_name)
):
continue
@@ -862,7 +906,7 @@ class SubcontractingController(StockController):
bom_item.main_item_code = row.item_code
self.__update_reserve_warehouse(bom_item, row)
self.__set_alternative_item(bom_item)
self.__add_supplied_item(row, bom_item, qty)
self.__add_supplied_or_received_item(row, bom_item, qty)
elif self.backflush_based_on != "BOM":
for key, transfer_item in self.available_materials.items():
@@ -872,7 +916,7 @@ class SubcontractingController(StockController):
) and transfer_item.qty > 0:
qty = flt(self.__get_qty_based_on_material_transfer(row, transfer_item))
transfer_item.qty -= qty
self.__add_supplied_item(row, transfer_item.get("item_details"), qty)
self.__add_supplied_or_received_item(row, transfer_item.get("item_details"), qty)
if self.qty_to_be_received:
self.qty_to_be_received[
@@ -940,13 +984,13 @@ class SubcontractingController(StockController):
):
return row
def __prepare_supplied_items(self):
def __prepare_supplied_or_received_items(self):
self.initialized_fields()
self.__get_subcontract_orders()
self.__get_pending_qty_to_receive()
self.get_available_materials()
self.__remove_changed_rows()
self.__set_supplied_items()
self.__set_supplied_or_received_items()
self.__modify_serial_and_batch_bundle()
self.__set_rate_for_serial_and_batch_bundle()
@@ -973,7 +1017,7 @@ class SubcontractingController(StockController):
msg = f"The Serial Nos {incorrect_sn} has not supplied against the {self.subcontract_data.order_doctype} {link}"
frappe.throw(_(msg), title=_("Incorrect Serial Number Consumed"))
def __validate_supplied_items(self):
def __validate_supplied_or_received_items(self):
if self.doctype not in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]:
return
@@ -991,10 +1035,10 @@ class SubcontractingController(StockController):
self.raw_material_table = raw_material_table
self.__identify_change_in_item_table()
self.__prepare_supplied_items()
self.__validate_supplied_items()
self.__prepare_supplied_or_received_items()
self.__validate_supplied_or_received_items()
def create_raw_materials_supplied(self, raw_material_table="supplied_items"):
def create_raw_materials_supplied_or_received(self, raw_material_table="supplied_items"):
self.set_materials_for_subcontracted_items(raw_material_table)
if self.doctype in ["Subcontracting Receipt", "Purchase Receipt", "Purchase Invoice"]:
@@ -1247,14 +1291,16 @@ def get_item_details(items):
return item_details
def get_pending_subcontracted_quantity(po_name):
table = frappe.qb.DocType("Purchase Order Item")
def get_pending_subcontracted_quantity(doctype, name):
table = frappe.qb.DocType(
"Purchase Order Item" if doctype == "Subcontracting Order" else "Sales Order Item"
)
query = (
frappe.qb.from_(table)
.select(table.name, table.qty, table.subcontracted_quantity)
.where(table.parent == po_name)
.select(table.name, table.qty, table.subcontracted_qty)
.where(table.parent == name)
)
return {item.name: item.qty - item.subcontracted_quantity for item in query.run(as_dict=True)}
return {item.name: item.qty - item.subcontracted_qty for item in query.run(as_dict=True)}
@frappe.whitelist()