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

@@ -0,0 +1,244 @@
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// client script for Subcontracting Inward Order Item is not necessarily required as the server side code will do everything that is necessary.
// this is just so that the user does not get potentially confused
frappe.ui.form.on("Subcontracting Inward Order Item", {
qty(frm, cdt, cdn) {
const row = locals[cdt][cdn];
const service_item = frm.doc.service_items[row.idx - 1];
frappe.model.set_value(
service_item.doctype,
service_item.name,
"qty",
row.qty * row.subcontracting_conversion_factor
);
frappe.model.set_value(service_item.doctype, service_item.name, "fg_item_qty", row.qty);
},
before_items_remove(frm, cdt, cdn) {
const row = locals[cdt][cdn];
frm.toggle_enable(["service_items"], true);
frm.get_field("service_items").grid.grid_rows[row.idx - 1].remove();
frm.toggle_enable(["service_items"], false);
},
});
frappe.ui.form.on("Subcontracting Inward Order", {
setup: (frm) => {
frm.get_field("items").grid.cannot_add_rows = true;
frm.set_query("customer_warehouse", () => {
return {
filters: {
is_group: 0,
is_rejected_warehouse: 0,
company: frm.doc.company,
customer: frm.doc.customer,
disabled: 0,
},
};
});
frm.set_query("sales_order", () => {
return {
filters: {
docstatus: 1,
is_subcontracted: 1,
},
};
});
frm.set_query("delivery_warehouse", "items", () => {
return {
filters: {
is_group: 0,
is_rejected_warehouse: 0,
company: frm.doc.company,
disabled: 0,
},
};
});
frm.set_query("set_delivery_warehouse", () => {
return {
filters: {
is_group: 0,
is_rejected_warehouse: 0,
company: frm.doc.company,
disabled: 0,
},
};
});
},
set_delivery_warehouse: (frm) => {
frm.doc.items.forEach((item) =>
frappe.model.set_value(
item.doctype,
item.name,
"delivery_warehouse",
frm.doc.set_delivery_warehouse
)
);
},
sales_order: (frm) => {
frm.set_value("service_items", null);
frm.set_value("items", null);
frm.set_value("received_items", null);
if (frm.doc.sales_order) {
erpnext.utils.map_current_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.make_subcontracting_inward_order",
source_name: frm.doc.sales_order,
target_doc: frm,
freeze: true,
freeze_message: __("Mapping Subcontracting Inward Order ..."),
});
}
},
refresh: function (frm) {
if (frm.doc.docstatus == 1) {
if (frm.has_perm("submit")) {
if (frm.doc.status == "Closed") {
frm.add_custom_button(
__("Re-open"),
() => frm.events.update_subcontracting_inward_order_status(frm),
__("Status")
);
} else {
frm.add_custom_button(
__("Close"),
() => frm.events.update_subcontracting_inward_order_status(frm, "Closed"),
__("Status")
);
}
}
if (frm.doc.status != "Closed") {
const is_raw_materials_received = frm.doc.received_items.some((item) =>
item.is_customer_provided_item
? item.received_qty - item.work_order_qty - item.returned_qty > 0
: false
);
if (is_raw_materials_received) {
frm.add_custom_button(
__("Raw Materials to Customer"),
() => frm.trigger("make_rm_return"),
__("Return")
);
if (frm.doc.per_produced < 100) {
frm.add_custom_button(
__("Work Order"),
() => frm.events.make_work_order(frm),
__("Create")
);
}
}
if (frm.doc.per_produced < 100) {
frm.add_custom_button(
__("Material from Customer"),
() => frm.events.make_stock_entry(frm),
__("Receive")
);
}
if (frm.doc.per_produced > 0 && frm.doc.per_delivered < 100) {
frm.add_custom_button(
__("Subcontracting Delivery"),
() => frm.events.make_subcontracting_delivery(frm),
__("Create")
);
}
if (frm.doc.per_delivered > 0 && frm.doc.per_returned < 100) {
frm.add_custom_button(
__("Finished Goods Return"),
() => frm.events.make_subcontracting_return(frm),
__("Return")
);
}
if (frm.doc.per_produced < 100) {
frm.page.set_inner_btn_group_as_primary(__("Receive"));
} else if (frm.doc.per_delivered < 100) {
frm.page.set_inner_btn_group_as_primary(__("Create"));
} else if (frm.doc.per_delivered >= 100 && frm.doc.per_returned < 100) {
frm.page.set_inner_btn_group_as_primary(__("Return"));
}
}
}
},
update_subcontracting_inward_order_status(frm, status) {
frappe.call({
method: "erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order.update_subcontracting_inward_order_status",
args: {
scio: frm.doc.name,
status: status,
},
callback: function (r) {
if (!r.exc) {
frm.reload_doc();
}
},
});
},
make_work_order(frm) {
frappe.call({
method: "make_work_order",
freeze: true,
doc: frm.doc,
callback: function () {
frm.reload_doc();
},
});
},
make_stock_entry(frm) {
frappe.call({
method: "make_rm_stock_entry_inward",
freeze: true,
doc: frm.doc,
callback: (r) => {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
},
});
},
make_rm_return(frm) {
frappe.call({
method: "make_rm_return",
freeze: true,
doc: frm.doc,
callback: (r) => {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
},
});
},
make_subcontracting_delivery(frm) {
frappe.call({
method: "make_subcontracting_delivery",
freeze: true,
doc: frm.doc,
callback: (r) => {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
},
});
},
make_subcontracting_return(frm) {
frappe.call({
method: "make_subcontracting_return",
freeze: true,
doc: frm.doc,
callback: (r) => {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
},
});
},
});

View File

@@ -0,0 +1,374 @@
{
"actions": [],
"allow_auto_repeat": 1,
"allow_import": 1,
"autoname": "naming_series:",
"creation": "2025-03-24 12:50:26.464612",
"doctype": "DocType",
"document_type": "Document",
"engine": "InnoDB",
"field_order": [
"title",
"naming_series",
"sales_order",
"customer",
"customer_name",
"currency",
"column_break_7",
"company",
"transaction_date",
"customer_warehouse",
"amended_from",
"items_section",
"set_delivery_warehouse",
"items",
"raw_materials_received_section",
"received_items",
"scrap_items_generated_section",
"scrap_items",
"service_items_section",
"service_items",
"tab_other_info",
"order_status_section",
"status",
"per_raw_material_received",
"per_produced",
"per_delivered",
"column_break_39",
"per_raw_material_returned",
"per_process_loss",
"per_returned",
"tab_connections"
],
"fields": [
{
"allow_on_submit": 1,
"default": "{customer_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
"no_copy": 1,
"print_hide": 1
},
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
"no_copy": 1,
"options": "SCI-ORD-.YYYY.-",
"print_hide": 1,
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "sales_order",
"fieldtype": "Link",
"label": "Subcontracting Sales Order",
"options": "Sales Order",
"reqd": 1
},
{
"bold": 1,
"fieldname": "customer",
"fieldtype": "Link",
"in_global_search": 1,
"in_standard_filter": 1,
"label": "Customer",
"options": "Customer",
"print_hide": 1,
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
"bold": 1,
"fetch_from": "customer.customer_name",
"fieldname": "customer_name",
"fieldtype": "Data",
"in_global_search": 1,
"label": "Customer Name",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_7",
"fieldtype": "Column Break",
"print_width": "50%",
"width": "50%"
},
{
"fieldname": "company",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Company",
"options": "Company",
"print_hide": 1,
"remember_last_selected_value": 1,
"reqd": 1
},
{
"default": "Today",
"fetch_from": "sales_order.transaction_date",
"fetch_if_empty": 1,
"fieldname": "transaction_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Date",
"reqd": 1,
"search_index": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Amended From",
"no_copy": 1,
"options": "Subcontracting Inward Order",
"print_hide": 1,
"read_only": 1
},
{
"allow_bulk_edit": 1,
"depends_on": "sales_order",
"fieldname": "items",
"fieldtype": "Table",
"label": "Items",
"options": "Subcontracting Inward Order Item",
"reqd": 1
},
{
"collapsible": 1,
"fieldname": "service_items_section",
"fieldtype": "Section Break",
"label": "Service Items"
},
{
"fieldname": "service_items",
"fieldtype": "Table",
"label": "Service Items",
"options": "Subcontracting Inward Order Service Item",
"read_only": 1,
"reqd": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "received_items",
"depends_on": "received_items",
"fieldname": "raw_materials_received_section",
"fieldtype": "Section Break",
"label": "Raw Materials Required"
},
{
"allow_on_submit": 1,
"fieldname": "received_items",
"fieldtype": "Table",
"label": "Required Items",
"no_copy": 1,
"options": "Subcontracting Inward Order Received Item",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "order_status_section",
"fieldtype": "Section Break",
"label": "Order Status"
},
{
"default": "Draft",
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "Draft\nOpen\nOngoing\nProduced\nDelivered\nCancelled\nClosed",
"print_hide": 1,
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
"fieldname": "column_break_39",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "per_delivered",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "% Delivered",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "tab_other_info",
"fieldtype": "Tab Break",
"label": "Other Info"
},
{
"fieldname": "tab_connections",
"fieldtype": "Tab Break",
"label": "Connections",
"show_dashboard": 1
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "per_produced",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "% Produced",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "items_section",
"fieldtype": "Section Break",
"label": "Items"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "per_process_loss",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "% Process Loss",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "set_delivery_warehouse",
"fieldtype": "Link",
"label": "Set Delivery Warehouse",
"no_copy": 1,
"options": "Warehouse"
},
{
"fieldname": "customer_warehouse",
"fieldtype": "Link",
"label": "Customer Warehouse",
"options": "Warehouse",
"reqd": 1
},
{
"depends_on": "scrap_items",
"fieldname": "scrap_items_generated_section",
"fieldtype": "Section Break",
"label": "Scrap Items Generated"
},
{
"fieldname": "scrap_items",
"fieldtype": "Table",
"label": "Scrap Items",
"no_copy": 1,
"options": "Subcontracting Inward Order Scrap Item"
},
{
"fieldname": "per_returned",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "% Returned",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "per_raw_material_returned",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "% Raw Material Returned",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "per_raw_material_received",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "% Raw Material Received",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fetch_from": "customer.default_currency",
"fieldname": "currency",
"fieldtype": "Link",
"hidden": 1,
"label": "Customer Currency",
"options": "Currency",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-09-05 14:41:46.859510",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Stock User",
"share": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1,
"submit": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"permlevel": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "status, transaction_date, customer",
"show_name_in_global_search": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"timeline_field": "customer",
"title_field": "customer_name",
"track_changes": 1
}

View File

@@ -0,0 +1,548 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.mapper import get_mapped_doc
from frappe.utils import comma_and, flt, get_link_to_form
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.subcontracting_controller import SubcontractingController
class SubcontractingInwardOrder(SubcontractingController):
# 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
from erpnext.subcontracting.doctype.subcontracting_inward_order_item.subcontracting_inward_order_item import (
SubcontractingInwardOrderItem,
)
from erpnext.subcontracting.doctype.subcontracting_inward_order_received_item.subcontracting_inward_order_received_item import (
SubcontractingInwardOrderReceivedItem,
)
from erpnext.subcontracting.doctype.subcontracting_inward_order_scrap_item.subcontracting_inward_order_scrap_item import (
SubcontractingInwardOrderScrapItem,
)
from erpnext.subcontracting.doctype.subcontracting_inward_order_service_item.subcontracting_inward_order_service_item import (
SubcontractingInwardOrderServiceItem,
)
amended_from: DF.Link | None
company: DF.Link
currency: DF.Link | None
customer: DF.Link
customer_name: DF.Data
customer_warehouse: DF.Link
items: DF.Table[SubcontractingInwardOrderItem]
naming_series: DF.Literal["SCI-ORD-.YYYY.-"]
per_delivered: DF.Percent
per_process_loss: DF.Percent
per_produced: DF.Percent
per_raw_material_received: DF.Percent
per_raw_material_returned: DF.Percent
per_returned: DF.Percent
received_items: DF.Table[SubcontractingInwardOrderReceivedItem]
sales_order: DF.Link
scrap_items: DF.Table[SubcontractingInwardOrderScrapItem]
service_items: DF.Table[SubcontractingInwardOrderServiceItem]
set_delivery_warehouse: DF.Link | None
status: DF.Literal["Draft", "Open", "Ongoing", "Produced", "Delivered", "Cancelled", "Closed"]
title: DF.Data | None
transaction_date: DF.Date
# end: auto-generated types
pass
def validate(self):
super().validate()
self.set_is_customer_provided_item()
self.validate_customer_provided_items()
self.validate_customer_warehouse()
self.validate_service_items()
self.set_missing_values()
def on_submit(self):
self.update_status()
self.update_subcontracted_quantity_in_so()
def on_cancel(self):
self.update_status()
self.update_subcontracted_quantity_in_so()
def update_status(self, status=None, update_modified=True):
if self.status == "Closed" and self.status != status:
check_on_hold_or_closed_status("Sales Order", self.sales_order)
total_to_be_received = total_received = total_rm_returned = 0
for rm in self.get("received_items"):
if rm.get("is_customer_provided_item"):
total_to_be_received += flt(rm.required_qty)
total_received += flt(rm.received_qty)
total_rm_returned += flt(rm.returned_qty)
total_to_be_produced = total_produced = total_process_loss = total_delivered = total_fg_returned = 0
for item in self.get("items"):
total_to_be_produced += flt(item.qty)
total_produced += flt(item.produced_qty)
total_process_loss += flt(item.process_loss_qty)
total_delivered += flt(item.delivered_qty)
total_fg_returned += flt(item.returned_qty)
per_raw_material_received = flt(total_received / total_to_be_received * 100, 2)
per_raw_material_returned = flt(total_rm_returned / total_received * 100, 2) if total_received else 0
per_produced = flt(total_produced / total_to_be_produced * 100, 2)
per_process_loss = flt(total_process_loss / total_produced * 100, 2) if total_produced else 0
per_delivered = flt(total_delivered / total_to_be_produced * 100, 2)
per_returned = flt(total_fg_returned / total_delivered * 100, 2) if total_delivered else 0
self.db_set("per_raw_material_received", per_raw_material_received, update_modified=update_modified)
self.db_set("per_raw_material_returned", per_raw_material_returned, update_modified=update_modified)
self.db_set("per_produced", per_produced, update_modified=update_modified)
self.db_set("per_process_loss", per_process_loss, update_modified=update_modified)
self.db_set("per_delivered", per_delivered, update_modified=update_modified)
self.db_set("per_returned", per_returned, update_modified=update_modified)
if self.docstatus >= 1 and not status:
if self.docstatus == 1:
if self.status == "Draft":
status = "Open"
elif self.per_delivered == 100:
status = "Delivered"
elif self.per_produced == 100:
status = "Produced"
elif self.per_raw_material_received > 0:
status = "Ongoing"
else:
status = "Open"
elif self.docstatus == 2:
status = "Cancelled"
if status and self.status != status:
self.db_set("status", status, update_modified=update_modified)
def update_subcontracted_quantity_in_so(self):
for service_item in self.service_items:
doc = frappe.get_doc("Sales Order Item", service_item.sales_order_item)
doc.subcontracted_qty = (
(doc.subcontracted_qty + service_item.qty)
if self._action == "submit"
else (doc.subcontracted_qty - service_item.qty)
)
doc.save()
def validate_customer_warehouse(self):
if frappe.get_cached_value("Warehouse", self.customer_warehouse, "customer") != self.customer:
frappe.throw(
_("Customer Warehouse {0} does not belong to Customer {1}.").format(
frappe.bold(self.customer_warehouse), frappe.bold(self.customer)
)
)
def validate_service_items(self):
sales_order_items = [item.sales_order_item for item in self.items]
self.service_items = [
service_item
for service_item in self.service_items
if service_item.sales_order_item in sales_order_items
]
for service_item in self.service_items:
item = next(item for item in self.items if item.sales_order_item == service_item.sales_order_item)
service_item.qty = item.qty * item.subcontracting_conversion_factor
service_item.fg_item_qty = item.qty
service_item.amount = service_item.qty * service_item.rate
def populate_items_table(self):
items = []
for si in self.service_items:
if si.fg_item:
item = frappe.get_doc("Item", si.fg_item)
so_item = frappe.get_doc("Sales Order Item", si.sales_order_item)
available_qty = so_item.qty - so_item.subcontracted_qty
if available_qty == 0:
continue
si.qty = available_qty
conversion_factor = so_item.qty / so_item.fg_item_qty
si.fg_item_qty = flt(
available_qty / conversion_factor, frappe.get_precision("Sales Order Item", "qty")
)
si.amount = available_qty * si.rate
bom = (
frappe.db.get_value(
"Subcontracting BOM",
{"finished_good": item.name, "is_active": 1},
"finished_good_bom",
)
or item.default_bom
)
items.append(
{
"item_code": item.name,
"item_name": item.item_name,
"expected_delivery_date": frappe.get_cached_value(
"Sales Order Item", si.sales_order_item, "delivery_date"
),
"description": item.description,
"qty": si.fg_item_qty,
"subcontracting_conversion_factor": conversion_factor,
"stock_uom": item.stock_uom,
"bom": bom,
"sales_order_item": si.sales_order_item,
}
)
else:
frappe.throw(
_("Please select Finished Good Item for Service Item {0}").format(
si.item_name or si.item_code
)
)
if items:
for item in items:
self.append("items", item)
def validate_customer_provided_items(self):
"""Check if atleast one raw material is customer provided"""
for item in self.get("items"):
raw_materials = [rm for rm in self.get("received_items") if rm.main_item_code == item.item_code]
if not any([rm.is_customer_provided_item for rm in raw_materials]):
frappe.throw(
_(
"Atleast one raw material for Finished Good Item {0} should be customer provided."
).format(frappe.bold(item.item_code))
)
def set_is_customer_provided_item(self):
for item in self.get("received_items"):
item.is_customer_provided_item = frappe.get_cached_value(
"Item", item.rm_item_code, "is_customer_provided_item"
)
@frappe.whitelist()
def make_work_order(self):
"""Create Work Order from Subcontracting Inward Order."""
wo_list = []
for item in self.get_production_items():
work_order = self.create_work_order(item)
if work_order:
wo_list.append(work_order)
self.show_list_created_message("Work Order", wo_list)
if not wo_list:
frappe.msgprint(_("No Work Orders were created"))
return wo_list
def get_production_items(self):
item_list = []
for d in self.items:
if d.produced_qty >= d.qty:
continue
item_details = {
"production_item": d.item_code,
"use_multi_level_bom": d.include_exploded_items,
"subcontracting_inward_order": self.name,
"bom_no": d.bom,
"stock_uom": d.stock_uom,
"company": self.company,
"project": frappe.get_cached_value("Sales Order", self.sales_order, "project"),
"source_warehouse": self.customer_warehouse,
"subcontracting_inward_order_item": d.name,
"reserve_stock": 1,
"fg_warehouse": d.delivery_warehouse,
}
qty = min(
[
flt(
(item.received_qty - item.returned_qty - item.work_order_qty)
/ flt(item.required_qty / d.qty, d.precision("qty")),
d.precision("qty"),
)
for item in self.get("received_items")
if item.reference_name == d.name and item.is_customer_provided_item
]
)
qty = int(qty) if frappe.get_cached_value("UOM", d.stock_uom, "must_be_whole_number") else qty
item_details.update({"qty": qty, "max_producible_qty": qty})
item_list.append(item_details)
return item_list
def create_work_order(self, item):
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
if flt(item.get("qty")) <= 0:
return
wo = frappe.new_doc("Work Order")
wo.update(item)
wo.set_work_order_operations()
wo.set_required_items()
try:
wo.flags.ignore_mandatory = True
wo.flags.ignore_validate = True
wo.insert()
return wo.name
except OverProductionError:
pass
def show_list_created_message(self, doctype, doc_list=None):
if not doc_list:
return
frappe.flags.mute_messages = False
if doc_list:
doc_list = [get_link_to_form(doctype, p) for p in doc_list]
frappe.msgprint(_("{0} created").format(comma_and(doc_list)))
@frappe.whitelist()
def make_rm_stock_entry_inward(self, target_doc=None):
def calculate_qty_as_per_bom(rm_item):
data = frappe.get_value(
"Subcontracting Inward Order Item",
{"name": rm_item.reference_name},
["process_loss_qty", "include_exploded_items"],
as_dict=True,
)
stock_qty = frappe.get_value(
"BOM Explosion Item" if data.include_exploded_items else "BOM Item",
{"name": rm_item.bom_detail_no},
"stock_qty",
)
qty = flt(
stock_qty * data.process_loss_qty,
frappe.get_precision("Subcontracting Inward Order Received Item", "required_qty"),
)
return rm_item.required_qty - rm_item.received_qty + rm_item.returned_qty + qty
if target_doc and target_doc.get("items"):
target_doc.items = []
stock_entry = get_mapped_doc(
"Subcontracting Inward Order",
self.name,
{
"Subcontracting Inward Order": {
"doctype": "Stock Entry",
"validation": {
"docstatus": ["=", 1],
},
},
},
target_doc,
ignore_child_tables=True,
)
stock_entry.purpose = "Receive from Customer"
stock_entry.subcontracting_inward_order = self.name
stock_entry.set_stock_entry_type()
for rm_item in self.received_items:
if not rm_item.required_qty or not rm_item.is_customer_provided_item:
continue
items_dict = {
rm_item.get("rm_item_code"): {
"scio_detail": rm_item.get("name"),
"qty": calculate_qty_as_per_bom(rm_item),
"to_warehouse": rm_item.get("warehouse"),
"stock_uom": rm_item.get("stock_uom"),
}
}
stock_entry.add_to_stock_entry_detail(items_dict)
if target_doc:
return stock_entry
else:
return stock_entry.as_dict()
@frappe.whitelist()
def make_rm_return(self, target_doc=None):
if target_doc and target_doc.get("items"):
target_doc.items = []
stock_entry = get_mapped_doc(
"Subcontracting Inward Order",
self.name,
{
"Subcontracting Inward Order": {
"doctype": "Stock Entry",
"validation": {
"docstatus": ["=", 1],
},
},
},
target_doc,
ignore_child_tables=True,
)
stock_entry.purpose = "Return Raw Material to Customer"
stock_entry.set_stock_entry_type()
stock_entry.subcontracting_inward_order = self.name
for rm_item in self.received_items:
items_dict = {
rm_item.get("rm_item_code"): {
"scio_detail": rm_item.get("name"),
"qty": rm_item.received_qty - rm_item.work_order_qty - rm_item.returned_qty,
"from_warehouse": rm_item.get("warehouse"),
"stock_uom": rm_item.get("stock_uom"),
}
}
stock_entry.add_to_stock_entry_detail(items_dict)
if target_doc:
return stock_entry
else:
return stock_entry.as_dict()
@frappe.whitelist()
def make_subcontracting_delivery(self, target_doc=None):
if target_doc and target_doc.get("items"):
target_doc.items = []
stock_entry = get_mapped_doc(
"Subcontracting Inward Order",
self.name,
{
"Subcontracting Inward Order": {
"doctype": "Stock Entry",
"validation": {
"docstatus": ["=", 1],
},
},
},
target_doc,
ignore_child_tables=True,
)
stock_entry.purpose = "Subcontracting Delivery"
stock_entry.set_stock_entry_type()
stock_entry.subcontracting_inward_order = self.name
scio_details = []
allow_over = frappe.get_single_value("Selling Settings", "allow_delivery_of_overproduced_qty")
for fg_item in self.items:
qty = (
fg_item.produced_qty
if allow_over
else min(fg_item.qty, fg_item.produced_qty) - fg_item.delivered_qty - fg_item.returned_qty
)
if qty < 0:
continue
scio_details.append(fg_item.name)
items_dict = {
fg_item.item_code: {
"qty": qty,
"from_warehouse": fg_item.delivery_warehouse,
"stock_uom": fg_item.stock_uom,
"scio_detail": fg_item.name,
"is_finished_item": 1,
}
}
stock_entry.add_to_stock_entry_detail(items_dict)
if (
frappe.get_single_value("Selling Settings", "deliver_scrap_items")
and self.scrap_items
and scio_details
):
scrap_items = [
scrap_item for scrap_item in self.scrap_items if scrap_item.reference_name in scio_details
]
for scrap_item in scrap_items:
qty = scrap_item.produced_qty - scrap_item.delivered_qty
if qty > 0:
items_dict = {
scrap_item.item_code: {
"qty": scrap_item.produced_qty - scrap_item.delivered_qty,
"from_warehouse": scrap_item.warehouse,
"stock_uom": scrap_item.stock_uom,
"scio_detail": scrap_item.name,
"is_scrap_item": 1,
}
}
stock_entry.add_to_stock_entry_detail(items_dict)
if target_doc:
return stock_entry
else:
return stock_entry.as_dict()
@frappe.whitelist()
def make_subcontracting_return(self, target_doc=None):
if target_doc and target_doc.get("items"):
target_doc.items = []
stock_entry = get_mapped_doc(
"Subcontracting Inward Order",
self.name,
{
"Subcontracting Inward Order": {
"doctype": "Stock Entry",
"validation": {
"docstatus": ["=", 1],
},
"field_map": {"name": "subcontracting_inward_order"},
},
},
target_doc,
ignore_child_tables=True,
)
stock_entry.purpose = "Subcontracting Return"
stock_entry.set_stock_entry_type()
for fg_item in self.items:
qty = fg_item.delivered_qty - fg_item.returned_qty
if qty < 0:
continue
items_dict = {
fg_item.item_code: {
"qty": qty,
"stock_uom": fg_item.stock_uom,
"scio_detail": fg_item.name,
"is_finished_item": 1,
}
}
stock_entry.add_to_stock_entry_detail(items_dict)
if target_doc:
return stock_entry
else:
return stock_entry.as_dict()
@frappe.whitelist()
def update_subcontracting_inward_order_status(scio, status=None):
if isinstance(scio, str):
scio = frappe.get_doc("Subcontracting Inward Order", scio)
scio.update_status(status)

View File

@@ -0,0 +1,17 @@
from frappe import _
def get_data():
return {
"fieldname": "subcontracting_inward_order",
"transactions": [
{
"label": _("Transactions"),
"items": ["Stock Entry"],
},
{
"label": _("Manufacturing"),
"items": ["Work Order"],
},
],
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.listview_settings["Subcontracting Inward Order"] = {
get_indicator: function (doc) {
const status_colors = {
Draft: "red",
Open: "orange",
Ongoing: "yellow",
Produced: "blue",
Delivered: "green",
Closed: "grey",
Cancelled: "red",
};
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
},
};

View File

@@ -0,0 +1,559 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_stock_entry_from_wo
from erpnext.selling.doctype.sales_order.sales_order import make_subcontracting_inward_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
class IntegrationTestSubcontractingInwardOrder(IntegrationTestCase):
"""
Integration tests for SubcontractingInwardOrder.
Use this class for testing interactions between multiple components.
"""
def setUp(self):
create_test_data()
make_stock_entry(
item_code="Self RM", qty=100, to_warehouse="Stores - _TC", purpose="Material Receipt"
)
return super().setUp()
def test_customer_provided_item_cost_field(self):
so, scio = create_so_scio()
rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in.save()
for item in rm_in.get("items"):
item.basic_rate = 10
rm_in.append(
"additional_costs",
{
"expense_account": "Freight and Forwarding Charges - _TC",
"description": "Test",
"amount": 100,
},
)
rm_in.submit()
for item in rm_in.get("items"):
self.assertEqual(item.customer_provided_item_cost, 15)
def test_add_extra_customer_provided_item(self):
so, scio = create_so_scio()
rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in.save()
rm_in.append(
"items",
{
"item_code": "Basic RM 2",
"qty": 5,
"t_warehouse": rm_in.items[0].t_warehouse,
"basic_rate": 10,
"transfer_qty": 5,
"uom": "Nos",
"conversion_factor": 1,
},
)
rm_in.submit()
scio.reload()
self.assertTrue(
next((item for item in scio.received_items if item.rm_item_code == "Basic RM 2"), None)
)
def test_add_extra_item_during_manufacture(self):
make_stock_entry(
item_code="Self RM 2", qty=5, to_warehouse="Stores - _TC", purpose="Material Receipt"
)
so, scio = create_so_scio()
frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit()
scio.reload()
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
wo.skip_transfer = 1
next(
item for item in wo.required_items if item.item_code == "Self RM"
).source_warehouse = "Stores - _TC"
wo.submit()
manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture"))
manufacture.save()
frappe.new_doc(
"Stock Entry Detail",
parent=manufacture.name,
parenttype="Stock Entry",
parentfield="items",
idx=6,
item_code="Self RM 2",
qty=5,
s_warehouse="Stores - _TC",
basic_rate=10,
transfer_qty=5,
uom="Nos",
conversion_factor=1,
cost_center="Main - _TC",
).insert()
manufacture.reload()
manufacture.submit()
scio.reload()
self.assertTrue(
next((item for item in scio.received_items if item.rm_item_code == "Self RM 2"), None)
)
def test_work_order_creation_qty(self):
new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001"))
new_bom.items = new_bom.items[:3]
new_bom.items[1].qty = 2
new_bom.items[2].qty = 3
new_bom.submit()
sc_bom = frappe.get_doc("Subcontracting BOM", "SB-0001")
sc_bom.finished_good_bom = new_bom.name
sc_bom.save()
so, scio = create_so_scio()
rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in.items[0].qty = 3
rm_in.items[1].qty = 5
rm_in.items[2].qty = 12
rm_in.submit()
scio.reload()
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
self.assertEqual(wo.qty, 2)
def test_rm_return(self):
from erpnext.stock.serial_batch_bundle import get_batch_nos, get_serial_nos
so, scio = create_so_scio()
rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in.items[3].qty = 2
rm_in.submit()
serial_nos = get_serial_nos(rm_in.items[3].serial_and_batch_bundle)
batch_nos = list(get_batch_nos(rm_in.items[3].serial_and_batch_bundle).keys())
scio.reload()
rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
backup = rm_in.items[-1]
rm_in.items.clear()
rm_in.items.append(backup)
rm_in.items[0].qty = 1
rm_in.submit()
serial_nos += get_serial_nos(rm_in.items[0].serial_and_batch_bundle)
batch_nos += list(get_batch_nos(rm_in.items[0].serial_and_batch_bundle).keys())
scio.reload()
rm_return = frappe.new_doc("Stock Entry").update(scio.make_rm_return())
rm_return.submit()
self.assertEqual(
sorted(get_serial_nos(rm_return.items[-1].serial_and_batch_bundle)), sorted(serial_nos)
)
self.assertEqual(
sorted(list(get_batch_nos(rm_return.items[-1].serial_and_batch_bundle).keys())), sorted(batch_nos)
)
def test_subcontracting_delivery(self):
from erpnext.stock.serial_batch_bundle import get_serial_batch_list_from_item
extra_serial, _ = get_serial_batch_list_from_item(
make_stock_entry(
item_code="FG Item with Serial",
qty=1,
to_warehouse="Stores - _TC",
purpose="Material Receipt",
).items[0]
)
so, scio = create_so_scio(service_item="Service Item 2", fg_item="FG Item with Serial")
frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit()
scio.reload()
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
wo.skip_transfer = 1
wo.required_items[-1].source_warehouse = "Stores - _TC"
wo.submit()
manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture"))
manufacture.submit()
serial_list, _ = get_serial_batch_list_from_item(
next(item for item in manufacture.items if item.is_finished_item)
)
scio.reload()
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
delivery.items[0].use_serial_batch_fields = 1
delivery.save()
delivery_serial_list, _ = get_serial_batch_list_from_item(delivery.items[0])
self.assertEqual(sorted(serial_list), sorted(delivery_serial_list))
delivery_serial_list[-1] = extra_serial[0]
delivery.items[0].serial_no = "\n".join(delivery_serial_list)
self.assertRaises(frappe.ValidationError, delivery.submit)
def test_fg_item_fields(self):
so, scio = create_so_scio()
frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit()
scio.reload()
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
wo.skip_transfer = 1
wo.required_items[-1].source_warehouse = "Stores - _TC"
wo.submit()
manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture"))
manufacture.save()
manufacture.fg_completed_qty = 5
manufacture.process_loss_qty = 1
manufacture.items[-1].qty = 4
manufacture.submit()
scio.reload()
self.assertEqual(scio.items[0].qty, 5)
self.assertEqual(scio.items[0].process_loss_qty, 1)
self.assertEqual(scio.items[0].produced_qty, 4)
rm_in = scio.make_rm_stock_entry_inward()
for item in rm_in.get("items"):
self.assertEqual(item.qty, 1)
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
delivery.items[0].qty = 5
self.assertRaises(frappe.ValidationError, delivery.submit)
delivery.items[0].qty = 2
delivery.submit()
scio.reload()
fg_return = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_return())
self.assertEqual(fg_return.items[0].qty, 2)
fg_return.items[0].qty = 1
fg_return.items[0].t_warehouse = "Stores - _TC"
fg_return.submit()
scio.reload()
self.assertEqual(scio.items[0].delivered_qty, 2)
self.assertEqual(scio.items[0].returned_qty, 1)
@IntegrationTestCase.change_settings("Selling Settings", {"allow_delivery_of_overproduced_qty": 1})
@IntegrationTestCase.change_settings(
"Manufacturing Settings", {"overproduction_percentage_for_work_order": 20}
)
def test_over_production_delivery(self):
so, scio = create_so_scio()
frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit()
scio.reload()
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
wo.skip_transfer = 1
wo.required_items[-1].source_warehouse = "Stores - _TC"
wo.submit()
manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture"))
manufacture.items[-1].qty = 6
manufacture.fg_completed_qty = 6
manufacture.submit()
scio.reload()
self.assertEqual(scio.items[0].produced_qty, 6)
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
self.assertEqual(delivery.items[0].qty, 6)
delivery.submit()
frappe.db.set_single_value("Selling Settings", "allow_delivery_of_overproduced_qty", 0)
delivery.cancel()
scio.reload()
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
self.assertEqual(delivery.items[0].qty, 5)
delivery.items[0].qty = 6
self.assertRaises(frappe.ValidationError, delivery.submit)
@IntegrationTestCase.change_settings("Selling Settings", {"deliver_scrap_items": 1})
def test_scrap_delivery(self):
new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001"))
new_bom.scrap_items.append(frappe.new_doc("BOM Scrap Item", item_code="Basic RM 2", qty=1))
new_bom.submit()
sc_bom = frappe.get_doc("Subcontracting BOM", "SB-0001")
sc_bom.finished_good_bom = new_bom.name
sc_bom.save()
so, scio = create_so_scio()
frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit()
scio.reload()
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
wo.skip_transfer = 1
wo.required_items[-1].source_warehouse = "Stores - _TC"
wo.submit()
frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")).submit()
scio.reload()
self.assertEqual(scio.scrap_items[0].item_code, "Basic RM 2")
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
self.assertEqual(delivery.items[-1].item_code, "Basic RM 2")
frappe.db.set_single_value("Selling Settings", "deliver_scrap_items", 0)
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
self.assertNotEqual(delivery.items[-1].item_code, "Basic RM 2")
def test_self_rm_billed_qty(self):
so, scio = create_so_scio()
frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit()
scio.reload()
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
wo.skip_transfer = 1
wo.required_items[-1].source_warehouse = "Stores - _TC"
wo.submit()
frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")).submit()
scio.reload()
frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()).submit()
scio.reload()
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
si = make_sales_invoice(so.name)
self.assertEqual(si.items[-1].item_code, "Self RM")
self.assertEqual(si.items[-1].qty, 5)
si.items[-1].qty = 3
si.submit()
scio.reload()
self.assertEqual(scio.received_items[-1].billed_qty, 3)
si = make_sales_invoice(so.name)
self.assertEqual(si.items[-1].qty, 2)
si.submit()
scio.reload()
self.assertEqual(scio.received_items[-1].billed_qty, 5)
scio.reload()
si = make_sales_invoice(so.name)
self.assertEqual(len(si.items), 1)
def test_extra_items_reservation_transfer(self):
so, scio = create_so_scio()
rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in.items[-2].qty = 7
rm_in.submit()
wo_list = []
scio.reload()
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
wo.skip_transfer = 1
wo.required_items[-1].source_warehouse = "Stores - _TC"
wo.qty = 3
wo.submit()
wo_list.append(wo.name)
self.assertEqual(wo.required_items[-2].stock_reserved_qty, 3)
scio.reload()
self.assertEqual(scio.received_items[-2].work_order_qty, 3)
wo = frappe.get_doc("Work Order", scio.make_work_order()[0])
wo.skip_transfer = 1
wo.required_items[-1].source_warehouse = "Stores - _TC"
wo.qty = 2
wo.submit()
wo_list.append(wo.name)
from frappe.query_builder.functions import Sum
table = frappe.qb.DocType("Stock Reservation Entry")
query = (
frappe.qb.from_(table)
.select(Sum(table.reserved_qty))
.where(
(table.voucher_type == "Work Order")
& (table.item_code == rm_in.items[-2].item_code)
& (table.voucher_no.isin(wo_list))
)
)
reserved_qty = query.run()[0][0]
self.assertEqual(reserved_qty, 7)
def create_so_scio(service_item="Service Item 1", fg_item="Basic FG Item"):
item_list = [{"item_code": service_item, "qty": 5, "fg_item": fg_item, "fg_item_qty": 5}]
so = make_sales_order(is_subcontracted=1, item_list=item_list)
scio = make_subcontracting_inward_order(so.name)
scio.items[0].delivery_warehouse = "_Test Warehouse - _TC"
scio.submit()
scio.reload()
return so, scio
def create_test_data():
make_subcontracted_items()
make_raw_materials()
make_service_items()
make_bom_for_subcontracted_items()
make_subcontracting_boms()
create_warehouse("_Test Customer Warehouse - _TC", {"customer": "_Test Customer"})
def make_subcontracted_items():
sub_contracted_items = {
"Basic FG Item": {},
"FG Item with Serial": {
"has_serial_no": 1,
"serial_no_series": "FGS.####",
},
"FG Item with Batch": {
"has_batch_no": 1,
"create_new_batch": 1,
"batch_series": "FGB.####",
},
"FG Item with Serial and Batch": {
"has_serial_no": 1,
"serial_no_series": "FGS.####",
"has_batch_no": 1,
"create_new_batch": 1,
"batch_series": "FGB.####",
},
}
for item, properties in sub_contracted_items.items():
if not frappe.db.exists("Item", item):
properties.update({"is_stock_item": 1, "is_sub_contracted_item": 1})
make_item(item, properties)
def make_raw_materials():
customer_provided_raw_materials = {
"Basic RM": {},
"Basic RM 2": {},
"RM with Serial": {"has_serial_no": 1, "serial_no_series": "RMS.####"},
"RM with Batch": {
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "RMB.####",
},
"RM with Serial and Batch": {
"has_serial_no": 1,
"serial_no_series": "RMS.####",
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "RMB.####",
},
}
for item, properties in customer_provided_raw_materials.items():
if not frappe.db.exists("Item", item):
properties.update({"is_stock_item": 1, "is_purchase_item": 0, "is_customer_provided_item": 1})
make_item(item, properties)
self_raw_materials = {
"Self RM": {},
"Self RM 2": {},
}
for item, properties in self_raw_materials.items():
if not frappe.db.exists("Item", item):
properties.update({"is_stock_item": 1, "valuation_rate": 10})
make_item(item, properties)
def make_service_items():
from erpnext.controllers.tests.test_subcontracting_controller import make_service_item
service_items = {
"Service Item 1": {},
"Service Item 2": {},
"Service Item 3": {},
"Service Item 4": {},
}
for item, properties in service_items.items():
make_service_item(item, properties)
def make_bom_for_subcontracted_items():
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
boms = {
"Basic FG Item": [
"Basic RM",
"RM with Serial",
"RM with Batch",
"RM with Serial and Batch",
"Self RM",
],
"FG Item with Serial": [
"Basic RM",
"RM with Serial",
"RM with Batch",
"RM with Serial and Batch",
"Self RM",
],
"FG Item with Batch": [
"Basic RM",
"RM with Serial",
"RM with Batch",
"RM with Serial and Batch",
"Self RM",
],
"FG Item with Serial and Batch": [
"Basic RM",
"RM with Serial",
"RM with Batch",
"RM with Serial and Batch",
"Self RM",
],
}
for item_code, raw_materials in boms.items():
if not frappe.db.exists("BOM", {"item": item_code}):
make_bom(
item=item_code, raw_materials=raw_materials, rate=100, currency="INR", set_as_default_bom=1
)
def make_subcontracting_boms():
subcontracting_boms = [
{
"finished_good": "Basic FG Item",
"service_item": "Service Item 1",
},
{
"finished_good": "FG Item with Serial",
"service_item": "Service Item 2",
},
{
"finished_good": "FG Item with Batch",
"service_item": "Service Item 3",
},
{
"finished_good": "FG Item with Serial and Batch",
"service_item": "Service Item 4",
},
]
for subcontracting_bom in subcontracting_boms:
if not frappe.db.exists("Subcontracting BOM", {"finished_good": subcontracting_bom["finished_good"]}):
doc = frappe.get_doc(
{
"doctype": "Subcontracting BOM",
"finished_good": subcontracting_bom["finished_good"],
"service_item": subcontracting_bom["service_item"],
"is_active": 1,
}
)
doc.insert()
doc.save()

View File

@@ -0,0 +1,202 @@
{
"actions": [],
"autoname": "hash",
"creation": "2025-03-24 12:53:33.849013",
"doctype": "DocType",
"document_type": "Document",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"item_name",
"column_break_3",
"bom",
"delivery_warehouse",
"include_exploded_items",
"quantity_section",
"qty",
"produced_qty",
"returned_qty",
"column_break_13",
"stock_uom",
"process_loss_qty",
"delivered_qty",
"conversion_factor",
"sales_order_item",
"subcontracting_conversion_factor"
],
"fields": [
{
"bold": 1,
"columns": 2,
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
"fetch_from": "item_code.item_name",
"fetch_if_empty": 1,
"fieldname": "item_name",
"fieldtype": "Data",
"in_global_search": 1,
"label": "Item Name",
"print_hide": 1,
"reqd": 1
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"bold": 1,
"columns": 1,
"default": "1",
"fieldname": "qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Quantity",
"non_negative": 1,
"print_width": "60px",
"reqd": 1,
"width": "60px"
},
{
"fieldname": "column_break_13",
"fieldtype": "Column Break",
"print_hide": 1
},
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"print_width": "100px",
"read_only": 1,
"reqd": 1,
"width": "100px"
},
{
"default": "1",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"hidden": 1,
"label": "Conversion Factor",
"read_only": 1
},
{
"depends_on": "item_code",
"fetch_from": "item_code.default_bom",
"fetch_if_empty": 1,
"fieldname": "bom",
"fieldtype": "Link",
"in_list_view": 1,
"label": "BOM",
"options": "BOM",
"print_hide": 1,
"reqd": 1
},
{
"default": "0",
"fieldname": "include_exploded_items",
"fieldtype": "Check",
"label": "Include Exploded Items",
"print_hide": 1
},
{
"default": "0",
"fieldname": "delivered_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Delivered Qty",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "returned_qty",
"fieldtype": "Float",
"label": "Returned Qty",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "sales_order_item",
"fieldtype": "Data",
"hidden": 1,
"label": "Sales Order Item",
"no_copy": 1,
"print_hide": 1,
"read_only": 1,
"search_index": 1
},
{
"fieldname": "subcontracting_conversion_factor",
"fieldtype": "Float",
"hidden": 1,
"label": "Subcontracting Conversion Factor",
"read_only": 1
},
{
"default": "0",
"fieldname": "produced_qty",
"fieldtype": "Float",
"label": "Produced Qty",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "process_loss_qty",
"fieldtype": "Float",
"label": "Process Loss Qty",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "quantity_section",
"fieldtype": "Section Break",
"label": "Quantity"
},
{
"fieldname": "delivery_warehouse",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Delivery Warehouse",
"no_copy": 1,
"options": "Warehouse",
"reqd": 1
}
],
"grid_page_length": 50,
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-14 10:29:29.256455",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order Item",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "item_name",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -0,0 +1,52 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
class SubcontractingInwardOrderItem(Document):
# 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
bom: DF.Link
conversion_factor: DF.Float
delivered_qty: DF.Float
delivery_warehouse: DF.Link
include_exploded_items: DF.Check
item_code: DF.Link
item_name: DF.Data
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
process_loss_qty: DF.Float
produced_qty: DF.Float
qty: DF.Float
returned_qty: DF.Float
sales_order_item: DF.Data | None
stock_uom: DF.Link
subcontracting_conversion_factor: DF.Float
# end: auto-generated types
pass
def update_manufacturing_qty_fields(self):
table = frappe.qb.DocType("Work Order")
query = (
frappe.qb.from_(table)
.select(
Sum(table.produced_qty).as_("produced_qty"),
Sum(table.process_loss_qty).as_("process_loss_qty"),
)
.where((table.subcontracting_inward_order_item == self.name) & (table.docstatus == 1))
)
result = query.run(as_dict=True)[0]
self.db_set("produced_qty", result.produced_qty)
self.db_set("process_loss_qty", result.process_loss_qty)

View File

@@ -0,0 +1,191 @@
{
"actions": [],
"creation": "2025-03-24 13:56:42.877800",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"main_item_code",
"rm_item_code",
"is_customer_provided_item",
"is_additional_item",
"column_break_3",
"stock_uom",
"warehouse",
"column_break_6",
"bom_detail_no",
"reference_name",
"section_break_13",
"required_qty",
"billed_qty",
"received_qty",
"column_break_16",
"consumed_qty",
"work_order_qty",
"returned_qty"
],
"fields": [
{
"columns": 2,
"fieldname": "main_item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"read_only": 1
},
{
"columns": 2,
"fieldname": "rm_item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Raw Material Item Code",
"options": "Item",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
},
{
"fieldname": "bom_detail_no",
"fieldtype": "Data",
"label": "BOM Detail No",
"read_only": 1
},
{
"fieldname": "reference_name",
"fieldtype": "Data",
"label": "Reference Name",
"read_only": 1
},
{
"fieldname": "section_break_13",
"fieldtype": "Section Break"
},
{
"columns": 2,
"default": "0",
"depends_on": "eval:doc.required_qty",
"fieldname": "required_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Required Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:doc.is_customer_provided_item",
"fieldname": "received_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Received Qty",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_16",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "consumed_qty",
"fieldtype": "Float",
"label": "Consumed Qty",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:doc.returned_qty",
"fieldname": "returned_qty",
"fieldtype": "Float",
"label": "Returned Qty",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"allow_on_submit": 1,
"default": "0",
"depends_on": "eval:doc.work_order_qty",
"fieldname": "work_order_qty",
"fieldtype": "Float",
"label": "Work Order Qty",
"no_copy": 1,
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "is_customer_provided_item",
"fieldtype": "Check",
"label": "Is Customer Provided Item",
"read_only": 1,
"reqd": 1
},
{
"depends_on": "eval:!doc.is_customer_provided_item",
"fieldname": "warehouse",
"fieldtype": "Link",
"label": "Warehouse",
"no_copy": 1,
"options": "Warehouse",
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:!doc.is_customer_provided_item",
"fieldname": "billed_qty",
"fieldtype": "Float",
"label": "Billed Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:!doc.bom_detail_no",
"fieldname": "is_additional_item",
"fieldtype": "Check",
"label": "Is Additional Item",
"read_only": 1
}
],
"grid_page_length": 50,
"hide_toolbar": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-14 10:18:58.905093",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order Received Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,36 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SubcontractingInwardOrderReceivedItem(Document):
# 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
billed_qty: DF.Float
bom_detail_no: DF.Data | None
consumed_qty: DF.Float
is_additional_item: DF.Check
is_customer_provided_item: DF.Check
main_item_code: DF.Link | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
received_qty: DF.Float
reference_name: DF.Data | None
required_qty: DF.Float
returned_qty: DF.Float
rm_item_code: DF.Link
stock_uom: DF.Link
warehouse: DF.Link | None
work_order_qty: DF.Float
# end: auto-generated types
pass

View File

@@ -0,0 +1,112 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-08-12 11:34:16.393300",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"fg_item_code",
"column_break_hoxe",
"stock_uom",
"warehouse",
"column_break_rptg",
"reference_name",
"section_break_gqk9",
"produced_qty",
"column_break_n4xc",
"delivered_qty"
],
"fields": [
{
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_hoxe",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_rptg",
"fieldtype": "Column Break"
},
{
"fieldname": "reference_name",
"fieldtype": "Data",
"label": "Reference Name",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "section_break_gqk9",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "produced_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Produced Qty",
"non_negative": 1,
"reqd": 1
},
{
"default": "0",
"fieldname": "delivered_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Delivered Qty",
"non_negative": 1,
"reqd": 1
},
{
"fieldname": "fg_item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Finished Good Item Code",
"options": "Item",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "warehouse",
"fieldtype": "Link",
"label": "Warehouse",
"options": "Warehouse",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_n4xc",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-14 10:28:30.192350",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order Scrap Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SubcontractingInwardOrderScrapItem(Document):
# 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
delivered_qty: DF.Float
fg_item_code: DF.Link
item_code: DF.Link
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
produced_qty: DF.Float
reference_name: DF.Data
stock_uom: DF.Link
warehouse: DF.Link
# end: auto-generated types
pass

View File

@@ -0,0 +1,147 @@
{
"actions": [],
"autoname": "hash",
"creation": "2025-03-24 14:01:02.572511",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"column_break_2",
"item_name",
"section_break_4",
"qty",
"uom",
"column_break_6",
"rate",
"amount",
"section_break_10",
"fg_item",
"column_break_12",
"fg_item_qty",
"sales_order_item"
],
"fields": [
{
"bold": 1,
"columns": 2,
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"reqd": 1,
"search_index": 1
},
{
"fetch_from": "item_code.item_name",
"fieldname": "item_name",
"fieldtype": "Data",
"in_global_search": 1,
"in_list_view": 1,
"label": "Item Name",
"print_hide": 1,
"reqd": 1
},
{
"bold": 1,
"columns": 1,
"fieldname": "qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Quantity",
"print_width": "60px",
"reqd": 1,
"width": "60px"
},
{
"bold": 1,
"columns": 2,
"fetch_from": "item_code.standard_rate",
"fetch_if_empty": 1,
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
"options": "currency",
"reqd": 1
},
{
"columns": 2,
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount",
"options": "currency",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "fg_item",
"fieldtype": "Link",
"label": "Finished Good Item",
"options": "Item",
"reqd": 1
},
{
"default": "1",
"fieldname": "fg_item_qty",
"fieldtype": "Float",
"label": "Finished Good Item Quantity",
"reqd": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_4",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_10",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"fieldname": "sales_order_item",
"fieldtype": "Data",
"hidden": 1,
"label": "Sales Order Item",
"read_only": 1,
"search_index": 1
},
{
"fieldname": "uom",
"fieldtype": "Link",
"label": "UOM",
"options": "UOM",
"read_only": 1,
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-09-05 13:33:49.154869",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order Service Item",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "item_name",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,31 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SubcontractingInwardOrderServiceItem(Document):
# 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
amount: DF.Currency
fg_item: DF.Link
fg_item_qty: DF.Float
item_code: DF.Link
item_name: DF.Data
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
qty: DF.Float
rate: DF.Currency
sales_order_item: DF.Data | None
uom: DF.Link
# end: auto-generated types
pass

View File

@@ -252,12 +252,12 @@ class SubcontractingOrder(SubcontractingController):
if si.fg_item:
item = frappe.get_doc("Item", si.fg_item)
qty, subcontracted_quantity, fg_item_qty = frappe.db.get_value(
qty, subcontracted_qty, fg_item_qty = frappe.db.get_value(
"Purchase Order Item",
si.purchase_order_item,
["qty", "subcontracted_quantity", "fg_item_qty"],
["qty", "subcontracted_qty", "fg_item_qty"],
)
available_qty = flt(qty) - flt(subcontracted_quantity)
available_qty = flt(qty) - flt(subcontracted_qty)
if available_qty == 0:
continue
@@ -342,23 +342,23 @@ class SubcontractingOrder(SubcontractingController):
def update_subcontracted_quantity_in_po(self, cancel=False):
for service_item in self.service_items:
subcontracted_quantity = flt(
subcontracted_qty = flt(
frappe.db.get_value(
"Purchase Order Item", service_item.purchase_order_item, "subcontracted_quantity"
"Purchase Order Item", service_item.purchase_order_item, "subcontracted_qty"
)
)
subcontracted_quantity = (
(subcontracted_quantity + service_item.qty)
subcontracted_qty = (
(subcontracted_qty + service_item.qty)
if not cancel
else (subcontracted_quantity - service_item.qty)
else (subcontracted_qty - service_item.qty)
)
frappe.db.set_value(
"Purchase Order Item",
service_item.purchase_order_item,
"subcontracted_quantity",
subcontracted_quantity,
"subcontracted_qty",
subcontracted_qty,
)