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

@@ -256,6 +256,18 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
sales_order_btn() {
var me = this;
let filters = {
docstatus: 1,
status: ["not in", ["Closed", "On Hold"]],
per_billed: ["<", 99.99],
company: me.frm.doc.company,
};
if (me.frm.doc.has_subcontracted) {
filters.is_subcontracted = 1;
}
this.$sales_order_btn = this.frm.add_custom_button(
__("Sales Order"),
function () {
@@ -266,12 +278,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
setters: {
customer: me.frm.doc.customer || undefined,
},
get_query_filters: {
docstatus: 1,
status: ["not in", ["Closed", "On Hold"]],
per_billed: ["<", 99.99],
company: me.frm.doc.company,
},
get_query_filters: filters,
allow_child_item_selection: true,
child_fieldname: "items",
child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"],
@@ -1094,6 +1101,9 @@ frappe.ui.form.on("Sales Invoice", {
if (frm.doc.is_debit_note) {
frm.set_df_property("return_against", "label", __("Adjustment Against"));
}
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
},
});

View File

@@ -31,6 +31,7 @@
"amended_from",
"is_created_using_pos",
"pos_closing_entry",
"has_subcontracted",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -2229,6 +2230,14 @@
"fieldtype": "Data",
"is_virtual": 1,
"label": "Last Scanned Warehouse"
},
{
"default": "0",
"fieldname": "has_subcontracted",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Subcontracted",
"read_only": 1
}
],
"grid_page_length": 50,
@@ -2242,7 +2251,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2025-09-09 14:48:59.472826",
"modified": "2025-10-09 14:48:59.472826",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -8,6 +8,7 @@ from frappe import _, msgprint, throw
from frappe.contacts.doctype.address.address import get_address_display
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.query_builder import Case
from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
from frappe.utils.data import comma_and
@@ -31,7 +32,6 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
update_voucher_outstanding,
)
@@ -131,6 +131,7 @@ class SalesInvoice(SellingController):
from_date: DF.Date | None
grand_total: DF.Currency
group_same_items: DF.Check
has_subcontracted: DF.Check
ignore_default_payment_terms_template: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.SmallText | None
@@ -336,6 +337,8 @@ class SalesInvoice(SellingController):
self.validate_auto_set_posting_time()
super().validate()
self.is_subcontracted()
if not (self.is_pos or self.is_debit_note):
self.so_dn_required()
@@ -408,6 +411,8 @@ class SalesInvoice(SellingController):
self.allow_write_off_only_on_pos()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.validate_subcontracted_sales_order()
self.validate_scio_self_rm_qty()
def validate_accounts(self):
self.validate_write_off_account()
@@ -574,6 +579,7 @@ class SalesInvoice(SellingController):
self.apply_loyalty_points()
self.process_common_party_accounting()
self.update_billed_qty_in_scio()
def validate_pos_return(self):
if self.is_consolidated:
@@ -704,6 +710,8 @@ class SalesInvoice(SellingController):
):
self.cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
self.update_billed_qty_in_scio()
def update_status_updater_args(self):
if not cint(self.update_stock):
return
@@ -838,6 +846,26 @@ class SalesInvoice(SellingController):
timesheet.set_status()
timesheet.db_update_all()
def update_billed_qty_in_scio(self):
table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
fieldname = table.returned_qty if self.is_return else table.billed_qty
data = frappe._dict(
{
item.scio_detail: item.stock_qty if self._action == "submit" else -item.stock_qty
for item in self.items
if item.scio_detail
}
)
if data:
case_expr = Case()
for name, qty in data.items():
case_expr = case_expr.when(table.name == name, fieldname + qty)
frappe.qb.update(table).set(fieldname, case_expr).where(
(table.name.isin(list(data.keys()))) & (table.docstatus == 1)
).run()
def update_time_sheet_detail(self, timesheet, args, sales_invoice):
for data in timesheet.time_logs:
if (
@@ -1230,6 +1258,50 @@ class SalesInvoice(SellingController):
if not self.is_pos and self.write_off_account:
self.write_off_account = None
def validate_subcontracted_sales_order(self):
if self.has_subcontracted:
if [item for item in self.items if not item.sales_order and not item.scio_detail]:
frappe.throw(
_(
"All items must be linked to a Sales Order or Subcontracting Inward Order for this Sales Invoice."
)
)
if not all(
frappe.get_all(
"Sales Order",
{"name": ["in", [item.sales_order for item in self.items if item.sales_order]]},
pluck="is_subcontracted",
)
):
frappe.throw(_("All linked Sales Orders must be subcontracted."))
def validate_scio_self_rm_qty(self):
self_rms = [item for item in self.items if item.scio_detail]
if self_rms:
table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
query = (
frappe.qb.from_(table)
.select(
table.required_qty, table.consumed_qty, table.billed_qty, table.returned_qty, table.name
)
.where((table.docstatus == 1) & (table.name.isin([item.scio_detail for item in self_rms])))
)
result = query.run(as_dict=True)
data = {item.name: item for item in result}
for item in self_rms:
row = data.get(item.scio_detail)
max_qty = max(row.required_qty, row.consumed_qty) - row.billed_qty - row.returned_qty
if item.stock_qty > max_qty:
frappe.throw(
_("Row #{0}: Stock quantity {1} ({2}) for item {3} cannot exceed {4}").format(
item.idx,
item.stock_qty,
item.stock_uom,
frappe.bold(item.item_code),
frappe.bold(max_qty),
)
)
def validate_write_off_account(self):
if flt(self.write_off_amount) and not self.write_off_account:
self.write_off_account = frappe.get_cached_value("Company", self.company, "write_off_account")
@@ -2151,6 +2223,23 @@ class SalesInvoice(SellingController):
if update:
self.db_set("status", self.status, update_modified=update_modified)
@frappe.whitelist()
def is_subcontracted(self):
if not self.has_subcontracted:
self.has_subcontracted = bool(
frappe.get_cached_value(
"Sales Order",
{
"name": ["in", [item.sales_order for item in self.items if item.sales_order]],
"is_subcontracted": 1,
},
"name",
)
)
if self.has_subcontracted:
self.update_stock = 0
return self.has_subcontracted
def get_total_in_party_account_currency(doc):
total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total"
@@ -2352,7 +2441,7 @@ def make_delivery_note(source_name, target_doc=None):
"cost_center": "cost_center",
},
"postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1,
"condition": lambda doc: doc.delivered_by_supplier != 1 and not doc.scio_detail,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {

View File

@@ -109,6 +109,7 @@
"column_break_vwhb",
"pos_invoice",
"pos_invoice_item",
"scio_detail",
"internal_transfer_section",
"purchase_order",
"column_break_92",
@@ -978,13 +979,20 @@
"options": "POS Invoice",
"print_hide": 1,
"search_index": 1
},
{
"fieldname": "scio_detail",
"fieldtype": "Data",
"hidden": 1,
"label": "SCIO Detail",
"read_only": 1
}
],
"grid_page_length": 50,
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-03-07 10:25:30.275246",
"modified": "2025-09-04 11:08:25.583561",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",
@@ -995,4 +1003,4 @@
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -84,6 +84,7 @@ class SalesInvoiceItem(Document):
rate_with_margin: DF.Currency
sales_invoice_item: DF.Data | None
sales_order: DF.Link | None
scio_detail: DF.Data | None
serial_and_batch_bundle: DF.Link | None
serial_no: DF.Text | None
service_end_date: DF.Date | None

View File

@@ -435,7 +435,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
);
}
} else {
if (!doc.items.every((item) => item.qty == item.subcontracted_quantity)) {
if (!doc.items.every((item) => item.qty == item.subcontracted_qty)) {
this.frm.add_custom_button(
__("Subcontracting Order"),
() => {

View File

@@ -972,7 +972,7 @@ def make_subcontracting_order(source_name, target_doc=None, save=False, submit=F
return target_doc
else:
frappe.throw(_("This PO has been fully subcontracted."))
frappe.throw(_("This Purchase Order has been fully subcontracted."))
def is_po_fully_subcontracted(po_name):
@@ -980,7 +980,7 @@ def is_po_fully_subcontracted(po_name):
query = (
frappe.qb.from_(table)
.select(table.name)
.where((table.parent == po_name) & (table.qty != table.subcontracted_quantity))
.where((table.parent == po_name) & (table.qty != table.subcontracted_qty))
)
return not query.run(as_dict=True)
@@ -1035,7 +1035,7 @@ def get_mapped_subcontracting_order(source_name, target_doc=None):
"material_request_item": "material_request_item",
},
"field_no_map": ["qty", "fg_item_qty", "amount"],
"condition": lambda item: item.qty != item.subcontracted_quantity,
"condition": lambda item: item.qty != item.subcontracted_qty,
},
},
target_doc,

View File

@@ -1095,9 +1095,9 @@ class TestPurchaseOrder(IntegrationTestCase):
# Test - 2: Subcontracted Quantity for the PO Items of each line item should be updated accordingly
po.reload()
self.assertEqual(po.items[0].subcontracted_quantity, 5)
self.assertEqual(po.items[1].subcontracted_quantity, 0)
self.assertEqual(po.items[2].subcontracted_quantity, 12.5)
self.assertEqual(po.items[0].subcontracted_qty, 5)
self.assertEqual(po.items[1].subcontracted_qty, 0)
self.assertEqual(po.items[2].subcontracted_qty, 12.5)
# Test - 3: Amount for both FG Item and its Service Item should be updated correctly based on change in Quantity
self.assertEqual(sco.items[0].amount, 2000)
@@ -1133,10 +1133,10 @@ class TestPurchaseOrder(IntegrationTestCase):
# Test - 8: Subcontracted Quantity for each PO Item should be subtracted if SCO gets cancelled
po.reload()
self.assertEqual(po.items[2].subcontracted_quantity, 25)
self.assertEqual(po.items[2].subcontracted_qty, 25)
sco.cancel()
po.reload()
self.assertEqual(po.items[2].subcontracted_quantity, 12.5)
self.assertEqual(po.items[2].subcontracted_qty, 12.5)
sco = make_subcontracting_order(po.name)
sco.save()

View File

@@ -26,7 +26,7 @@
"quantity_and_rate",
"qty",
"stock_uom",
"subcontracted_quantity",
"subcontracted_qty",
"col_break2",
"uom",
"conversion_factor",
@@ -933,8 +933,9 @@
},
{
"allow_on_submit": 1,
"default": "0",
"depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow",
"fieldname": "subcontracted_quantity",
"fieldname": "subcontracted_qty",
"fieldtype": "Float",
"label": "Subcontracted Quantity",
"no_copy": 1,
@@ -947,7 +948,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-03-13 17:27:43.468602",
"modified": "2025-10-12 10:57:31.552812",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -85,7 +85,7 @@ class PurchaseOrderItem(Document):
stock_qty: DF.Float
stock_uom: DF.Link
stock_uom_rate: DF.Currency
subcontracted_quantity: DF.Float
subcontracted_qty: DF.Float
supplier_part_no: DF.Data | None
supplier_quotation: DF.Link | None
supplier_quotation_item: DF.Link | None

View File

@@ -3804,9 +3804,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
any_qty_changed = True
if (
parent.doctype == "Purchase Order"
parent.doctype in ["Sales Order", "Purchase Order"]
and parent.is_subcontracted
and not parent.is_old_subcontracting_flow
and not parent.get("is_old_subcontracting_flow")
):
validate_fg_item_for_subcontracting(d, new_child_flag)
child_item.fg_item_qty = flt(d["fg_item_qty"])
@@ -3891,7 +3891,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.set_qty_as_per_stock_uom()
parent.calculate_taxes_and_totals()
parent.set_total_in_words()
if parent_doctype == "Sales Order":
if parent_doctype == "Sales Order" and not parent.is_subcontracted:
make_packing_list(parent)
parent.set_gross_profit()
frappe.get_cached_doc("Authorization Control").validate_approving_authority(
@@ -3938,6 +3938,12 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
).format(frappe.bold(parent.name))
)
else: # Sales Order
if parent.is_subcontracted and not parent.can_update_items():
frappe.throw(
_(
"Items cannot be updated as Subcontracting Inward Order is created against the Sales Order {0}."
).format(frappe.bold(parent.name))
)
parent.validate_selling_price()
parent.validate_for_duplicate_items()
parent.validate_warehouse()
@@ -3957,7 +3963,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.validate_uom_is_integer("stock_uom", "stock_qty")
# Cancel and Recreate Stock Reservation Entries.
if parent_doctype == "Sales Order":
if parent_doctype == "Sales Order" and not parent.is_subcontracted:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries,
has_reserved_stock,

View File

@@ -95,9 +95,11 @@ class StockController(AccountsController):
"Stock Reconciliation",
]:
for item in self.get("items"):
if (item.get("valuation_rate") == 0 or item.get("incoming_rate") == 0) and item.get(
"allow_zero_valuation_rate"
) == 0:
if (
(item.get("valuation_rate") == 0 or item.get("incoming_rate") == 0)
and item.get("allow_zero_valuation_rate") == 0
and frappe.get_cached_value("Item", item.item_code, "is_stock_item")
):
frappe.toast(
_(
"Row #{0}: Item {1} has zero rate but 'Allow Zero Valuation Rate' is not enabled."
@@ -544,10 +546,14 @@ class StockController(AccountsController):
break
elif row.batch_no:
batches = frappe.get_all(
"Serial and Batch Entry", fields=["batch_no"], filters={"parent": row.serial_and_batch_bundle}
batches = sorted(
frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle, "batch_no": ("is", "set")},
pluck="batch_no",
distinct=True,
)
)
batches = sorted([d.batch_no for d in batches])
if batches != [row.batch_no]:
throw_error = True

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()

File diff suppressed because it is too large Load Diff

View File

@@ -72,7 +72,7 @@ class TestSubcontractingController(IntegrationTestCase):
def test_create_raw_materials_supplied(self):
sco = get_subcontracting_order()
sco.supplied_items = None
sco.create_raw_materials_supplied()
sco.create_raw_materials_supplied_or_received()
self.assertIsNotNone(sco.supplied_items)
def test_sco_with_bom(self):

View File

@@ -220,9 +220,9 @@ class BOM(WebsiteGenerator):
def onload(self):
super().onload()
self.set_onload_for_muulti_level_bom()
self.set_onload_for_multi_level_bom()
def set_onload_for_muulti_level_bom(self):
def set_onload_for_multi_level_bom(self):
use_multi_level_bom = frappe.db.get_value(
"Property Setter",
{"field_name": "use_multi_level_bom", "doc_type": "Work Order", "property": "default"},

View File

@@ -936,7 +936,7 @@ class ProductionPlan(Document):
material_request_type = item.material_request_type or item_doc.default_material_request_type
# key for Sales Order:Material Request Type:Customer
key = "{}:{}:{}".format(item.sales_order, material_request_type, item_doc.customer or "")
key = "{}:{}:{}".format(item.sales_order, material_request_type, "")
schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days))
if key not in material_request_map:
@@ -949,7 +949,6 @@ class ProductionPlan(Document):
"status": "Draft",
"company": self.company,
"material_request_type": material_request_type,
"customer": item_doc.customer or "",
}
)
material_request_list.append(material_request)

View File

@@ -692,7 +692,6 @@ class TestProductionPlan(IntegrationTestCase):
mr = frappe.get_doc("Material Request", material_request)
self.assertTrue(mr.material_request_type, "Customer Provided")
self.assertTrue(mr.customer, "_Test Customer")
def test_production_plan_with_multi_level_bom(self):
"""
@@ -2524,4 +2523,7 @@ def make_bom(**args):
if not args.do_not_submit:
bom.submit()
if args.set_as_default_bom and not args.do_not_save and not args.do_not_submit:
frappe.set_value("Item", args.item, "default_bom", bom.name)
return bom

View File

@@ -15,6 +15,8 @@
"image",
"bom_no",
"mps",
"subcontracting_inward_order",
"subcontracting_inward_order_item",
"sales_order",
"column_break1",
"company",
@@ -23,6 +25,7 @@
"track_semi_finished_goods",
"reserve_stock",
"column_break_agjv",
"max_producible_qty",
"material_transferred_for_manufacturing",
"additional_transferred_qty",
"produced_qty",
@@ -154,6 +157,7 @@
},
{
"default": "0",
"depends_on": "eval:!doc.subcontracting_inward_order",
"fieldname": "allow_alternative_item",
"fieldtype": "Check",
"label": "Allow Alternative Item"
@@ -164,7 +168,8 @@
"fieldname": "use_multi_level_bom",
"fieldtype": "Check",
"label": "Use Multi-Level BOM",
"print_hide": 1
"print_hide": 1,
"read_only_depends_on": "eval:doc.subcontracting_inward_order"
},
{
"default": "0",
@@ -219,6 +224,7 @@
"read_only": 1
},
{
"depends_on": "eval:!doc.subcontracting_inward_order",
"fieldname": "sales_order",
"fieldtype": "Link",
"in_global_search": 1,
@@ -235,7 +241,7 @@
},
{
"default": "0",
"depends_on": "skip_transfer",
"depends_on": "eval:doc.skip_transfer && !doc.subcontracting_inward_order",
"fieldname": "from_wip_warehouse",
"fieldtype": "Check",
"label": "Backflush Raw Materials From Work-in-Progress Warehouse"
@@ -247,6 +253,7 @@
"options": "fa fa-building"
},
{
"depends_on": "eval:!(doc.skip_transfer && doc.subcontracting_inward_order)",
"description": "This is a location where operations are executed.",
"fieldname": "wip_warehouse",
"fieldtype": "Link",
@@ -259,7 +266,8 @@
"fieldname": "fg_warehouse",
"fieldtype": "Link",
"label": "Target Warehouse",
"options": "Warehouse"
"options": "Warehouse",
"read_only_depends_on": "subcontracting_inward_order"
},
{
"fieldname": "column_break_12",
@@ -418,6 +426,7 @@
"width": "50%"
},
{
"depends_on": "eval:!doc.subcontracting_inward_order",
"description": "Manufacture against Material Request",
"fieldname": "material_request",
"fieldtype": "Link",
@@ -495,7 +504,8 @@
"fieldname": "source_warehouse",
"fieldtype": "Link",
"label": "Source Warehouse",
"options": "Warehouse"
"options": "Warehouse",
"read_only_depends_on": "eval:doc.subcontracting_inward_order"
},
{
"description": "In Mins",
@@ -595,7 +605,8 @@
"default": "0",
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": " Reserve Stock"
"label": "Reserve Stock",
"read_only_depends_on": "subcontracting_inward_order"
},
{
"depends_on": "eval:doc.docstatus==1",
@@ -622,6 +633,32 @@
"label": "Additional Transferred Qty",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "subcontracting_inward_order",
"fieldname": "subcontracting_inward_order",
"fieldtype": "Link",
"label": "Subcontracting Inward Order",
"options": "Subcontracting Inward Order",
"read_only": 1
},
{
"fieldname": "subcontracting_inward_order_item",
"fieldtype": "Data",
"hidden": 1,
"label": "Subcontracting Inward Order Item",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "max_producible_qty",
"fieldtype": "Float",
"hidden": 1,
"label": "Max Producible Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
}
],
"grid_page_length": 50,
@@ -630,7 +667,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2025-09-29 15:57:47.022616",
"modified": "2025-10-12 14:24:57.699749",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -102,6 +102,7 @@ class WorkOrder(Document):
material_request: DF.Link | None
material_request_item: DF.Data | None
material_transferred_for_manufacturing: DF.Float
max_producible_qty: DF.Float
mps: DF.Link | None
naming_series: DF.Literal["MFG-WO-.YYYY.-"]
operations: DF.Table[WorkOrderOperation]
@@ -138,6 +139,8 @@ class WorkOrder(Document):
"Cancelled",
]
stock_uom: DF.Link | None
subcontracting_inward_order: DF.Link | None
subcontracting_inward_order_item: DF.Data | None
total_operating_cost: DF.Currency
track_semi_finished_goods: DF.Check
transfer_material_against: DF.Literal["", "Work Order", "Job Card"]
@@ -180,7 +183,11 @@ class WorkOrder(Document):
if self.bom_no:
validate_bom_no(self.production_item, self.bom_no)
self.validate_sales_order()
if not self.subcontracting_inward_order:
self.validate_sales_order()
else:
self.validate_self_rm_warehouse()
self.set_default_warehouse()
self.validate_warehouse_belongs_to_company()
self.check_wip_warehouse_skip()
@@ -203,6 +210,7 @@ class WorkOrder(Document):
self.set_required_items(reset_only_qty=len(self.get("required_items")))
self.enable_auto_reserve_stock()
self.validate_operations_sequence()
self.validate_subcontracting_inward_order()
def validate_dates(self):
if self.actual_start_date and self.actual_end_date:
@@ -210,7 +218,7 @@ class WorkOrder(Document):
frappe.throw(_("Actual End Date cannot be before Actual Start Date"))
def validate_fg_warehouse_for_reservation(self):
if self.reserve_stock and self.sales_order:
if self.reserve_stock and self.sales_order and not self.subcontracting_inward_order:
warehouses = frappe.get_all(
"Sales Order Item",
filters={"parent": self.sales_order, "item_code": self.production_item},
@@ -257,6 +265,24 @@ class WorkOrder(Document):
)
sequence_id = op.sequence_id
def validate_subcontracting_inward_order(self):
if scio := self.subcontracting_inward_order:
if self.source_warehouse != (
rm_receipt_warehouse := frappe.get_cached_value(
"Subcontracting Inward Order",
scio,
"customer_warehouse",
)
):
frappe.throw(
_(
"Source Warehouse {0} must be same as Customer Warehouse {1} in the Subcontracting Inward Order"
).format(
frappe.bold(self.source_warehouse),
frappe.bold(rm_receipt_warehouse),
)
)
def set_warehouses(self):
for row in self.required_items:
if not row.source_warehouse:
@@ -330,6 +356,15 @@ class WorkOrder(Document):
else:
frappe.throw(_("Sales Order {0} is not valid").format(self.sales_order))
def validate_self_rm_warehouse(self):
for item in [item for item in self.required_items if not item.is_customer_provided_item]:
if frappe.get_cached_value("Warehouse", item.source_warehouse, "customer"):
frappe.throw(
_("Row #{0}: Source Warehouse {1} for item {2} cannot be a customer warehouse.").format(
item.idx, frappe.bold(item.source_warehouse), frappe.bold(item.item_code)
)
)
def check_sales_order_on_hold_or_close(self):
status = frappe.db.get_value("Sales Order", self.sales_order, "status")
if status in ("Closed", "On Hold"):
@@ -636,6 +671,8 @@ class WorkOrder(Document):
if self.reserve_stock:
self.update_stock_reservation()
self.update_subcontracting_inward_order_received_items()
def on_cancel(self):
self.validate_cancel()
self.db_set("status", "Cancelled")
@@ -657,10 +694,68 @@ class WorkOrder(Document):
if self.reserve_stock:
self.update_stock_reservation()
self.update_subcontracting_inward_order_received_items()
def update_stock_reservation(self):
self.set_qty_change()
make_stock_reservation_entries(self)
self.db_set("status", self.get_status())
def set_qty_change(self):
if scio_item_name := self.get("subcontracting_inward_order_item"):
scio_rm_item_names = frappe.db.get_all(
"Subcontracting Inward Order Received Item",
filters={"reference_name": scio_item_name, "docstatus": 1, "is_customer_provided_item": 1},
pluck="name",
)
self.qty_change = frappe._dict()
data = frappe.get_all(
"Subcontracting Inward Order Received Item",
{"name": ["in", scio_rm_item_names]},
["rm_item_code", "required_qty as bom_qty", "work_order_qty", "received_qty"],
)
for d in data:
wo_item = next(
wo_item for wo_item in self.get("required_items") if wo_item.item_code == d.rm_item_code
)
if (
d.work_order_qty + (wo_item.required_qty if self._action == "submit" else 0)
) == d.bom_qty and d.received_qty > d.bom_qty:
self.qty_change[wo_item.name] = d.received_qty - d.bom_qty
def update_subcontracting_inward_order_received_items(self):
if scio_item_name := self.get("subcontracting_inward_order_item"):
scio_rm_data = frappe.get_all(
"Subcontracting Inward Order Received Item",
filters={"reference_name": scio_item_name, "docstatus": 1},
fields=["name", "rm_item_code"],
)
required_qty = {
wo_item.item_code: wo_item.required_qty
for wo_item in self.get("required_items")
if wo_item.item_code in [d.rm_item_code for d in scio_rm_data]
}
table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
case_expr = Case()
for item in scio_rm_data:
case_expr = case_expr.when(
table.rm_item_code == item.rm_item_code,
table.work_order_qty
+ (
required_qty[item.rm_item_code]
if self._action == "submit"
else -required_qty[item.rm_item_code]
),
)
frappe.qb.update(table).set(table.work_order_qty, case_expr).where(
(table.name.isin([d.name for d in scio_rm_data])) & (table.docstatus == 1)
).run()
def create_serial_no_batch_no(self):
if not (self.has_serial_no or self.has_batch_no):
return
@@ -1229,6 +1324,15 @@ class WorkOrder(Document):
OverProductionError,
)
if self.subcontracting_inward_order and self.qty > self.max_producible_qty:
frappe.msgprint(
_(
"Warning: Quantity exceeds maximum producible quantity based on quantity of raw materials received through the Subcontracting Inward Order {0}."
).format(frappe.bold(self.subcontracting_inward_order)),
alert=True,
indicator="orange",
)
def validate_transfer_against(self):
if self.docstatus != 1:
# let user configure operations until they're ready to submit
@@ -1330,6 +1434,11 @@ class WorkOrder(Document):
},
)
if self.subcontracting_inward_order and not frappe.get_cached_value(
"Item", item.item_code, "is_customer_provided_item"
):
self.required_items[-1].source_warehouse = item.default_warehouse
if not self.project:
self.project = item.get("project")
@@ -1532,7 +1641,7 @@ class WorkOrder(Document):
stock_entry.reload()
if stock_entry.purpose == "Manufacture" and (
self.sales_order or self.production_plan_sub_assembly_item
self.sales_order or self.production_plan_sub_assembly_item or self.subcontracting_inward_order
):
items = self.get_finished_goods_for_reservation(stock_entry)
elif stock_entry.purpose == "Material Transfer for Manufacture":
@@ -1577,6 +1686,8 @@ class WorkOrder(Document):
if self.production_plan_sub_assembly_item:
# Reserve the sub-assembly item for the final product for the work order.
item_details = self.get_wo_details()
elif self.subcontracting_inward_order:
item_details = self.get_scio_details()
else:
# Reserve the final product for the sales order.
item_details = self.get_so_details()
@@ -1665,6 +1776,25 @@ class WorkOrder(Document):
return query.run(as_dict=1)
def get_scio_details(self):
return frappe.get_all(
"Subcontracting Inward Order Item",
filters={
"name": self.subcontracting_inward_order_item,
"docstatus": 1,
},
fields=[
"item_code",
"name",
"qty as stock_qty",
"produced_qty as stock_reserved_qty",
"delivery_warehouse as warehouse",
"parent as voucher_no",
"parenttype as voucher_type",
"delivered_qty",
],
)
def get_so_details(self):
return frappe.get_all(
"Sales Order Item",
@@ -1767,7 +1897,8 @@ class WorkOrder(Document):
@frappe.whitelist()
def make_stock_reservation_entries(doc, items=None, table_name=None, is_transfer=True, notify=False):
def make_stock_reservation_entries(doc, items=None, is_transfer=True, notify=False):
is_transfer = cint(is_transfer)
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Work Order", doc.get("name"))
@@ -1781,6 +1912,14 @@ def make_stock_reservation_entries(doc, items=None, table_name=None, is_transfer
sre.transfer_reservation_entries_to(
doc.production_plan, from_doctype="Production Plan", to_doctype="Work Order"
)
elif doc.subcontracting_inward_order and is_transfer:
sre.transfer_reservation_entries_to(
doc.subcontracting_inward_order,
from_doctype="Subcontracting Inward Order",
to_doctype="Work Order",
against_fg_item=doc.subcontracting_inward_order_item,
qty_change=doc.qty_change,
)
else:
sre_created = sre.make_stock_reservation_entries()
if sre_created:
@@ -2045,6 +2184,9 @@ def make_stock_entry(
qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty))
)
if purpose == "Manufacture" and work_order.subcontracting_inward_order:
stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order
if work_order.bom_no:
stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")

View File

@@ -29,6 +29,7 @@
"column_break_jash",
"stock_reserved_qty",
"is_additional_item",
"is_customer_provided_item",
"voucher_detail_reference"
],
"fields": [
@@ -52,7 +53,8 @@
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Source Warehouse",
"options": "Warehouse"
"options": "Warehouse",
"read_only_depends_on": "eval:parent.subcontracting_inward_order && doc.is_customer_provided_item"
},
{
"fieldname": "column_break_3",
@@ -91,6 +93,7 @@
},
{
"default": "0",
"depends_on": "eval:!parent.subcontracting_inward_order",
"fieldname": "allow_alternative_item",
"fieldtype": "Check",
"label": "Allow Alternative Item"
@@ -190,12 +193,20 @@
"label": "Voucher Detail Reference",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"fetch_from": "item_code.is_customer_provided_item",
"fieldname": "is_customer_provided_item",
"fieldtype": "Check",
"label": "Is Customer Provided Item",
"read_only": 1
}
],
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2025-05-12 17:36:00.115181",
"modified": "2025-10-12 14:27:16.721532",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -23,6 +23,7 @@ class WorkOrderItem(Document):
description: DF.Text | None
include_item_in_manufacturing: DF.Check
is_additional_item: DF.Check
is_customer_provided_item: DF.Check
item_code: DF.Link | None
item_name: DF.Data | None
operation: DF.Link | None

View File

@@ -440,3 +440,5 @@ erpnext.patches.v16_0.make_workstation_operating_components #1
erpnext.patches.v16_0.set_reporting_currency
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes
erpnext.patches.v16_0.update_serial_no_reference_name
erpnext.patches.v16_0.rename_subcontracted_quantity
erpnext.patches.v16_0.add_new_stock_entry_types

View File

@@ -0,0 +1,14 @@
import frappe
def execute():
for stock_entry_type in [
"Receive from Customer",
"Return Raw Material to Customer",
"Subcontracting Delivery",
"Subcontracting Return",
]:
if not frappe.db.exists("Stock Entry Type", stock_entry_type):
frappe.new_doc("Stock Entry Type", purpose=stock_entry_type, is_standard=1).insert(
set_name=stock_entry_type, ignore_permissions=True
)

View File

@@ -0,0 +1,7 @@
import frappe
from frappe.model.utils.rename_field import rename_field
def execute():
if frappe.db.has_column("Purchase Order Item", "subcontracted_quantity"):
rename_field("Purchase Order Item", "subcontracted_quantity", "subcontracted_qty")

View File

@@ -195,6 +195,7 @@ $.extend(erpnext.stock_reservation, {
args: {
doc: frm.doc,
items: data.items,
is_transfer: 0,
table_name: table_name,
notify: true,
},

View File

@@ -645,7 +645,7 @@ erpnext.utils.update_child_items = function (opts) {
get_query: function () {
let filters;
if (frm.doc.doctype == "Sales Order") {
filters = { is_sales_item: 1 };
filters = { is_sales_item: 1, is_stock_item: !frm.doc.is_subcontracted };
} else if (frm.doc.doctype == "Purchase Order") {
if (frm.doc.is_subcontracted) {
if (frm.doc.is_old_subcontracting_flow) {
@@ -801,7 +801,7 @@ erpnext.utils.update_child_items = function (opts) {
}
if (
frm.doc.doctype == "Purchase Order" &&
["Purchase Order", "Sales Order"].includes(frm.doc.doctype) &&
frm.doc.is_subcontracted &&
!frm.doc.is_old_subcontracting_flow
) {
@@ -857,7 +857,7 @@ erpnext.utils.update_child_items = function (opts) {
},
],
primary_action: function () {
if (frm.doctype == "Sales Order" && has_reserved_stock) {
if (frm.doctype == "Sales Order" && has_reserved_stock && frm.doc.is_subcontracted == 0) {
this.hide();
frappe.confirm(
__(

View File

@@ -543,6 +543,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
based_on: based_on,
posting_date: this.frm.doc.posting_date,
posting_time: this.frm.doc.posting_time,
scio_detail: this.item.scio_detail,
},
callback: (r) => {
if (r.message) {

View File

@@ -54,7 +54,8 @@ frappe.ui.form.on("Sales Order", {
frm.doc.status !== "Closed" &&
flt(frm.doc.per_delivered) < 100 &&
flt(frm.doc.per_billed) < 100 &&
frm.has_perm("write")
frm.has_perm("write") &&
!frm.doc.is_subcontracted
) {
frm.add_custom_button(__("Update Items"), () => {
erpnext.utils.update_child_items({
@@ -84,7 +85,8 @@ frappe.ui.form.on("Sales Order", {
if (
frm.doc.__onload &&
frm.doc.__onload.has_reserved_stock &&
frappe.model.can_cancel("Stock Reservation Entry")
frappe.model.can_cancel("Stock Reservation Entry") &&
!frm.doc.is_subcontracted
) {
frm.add_custom_button(
__("Unreserve"),
@@ -93,16 +95,21 @@ frappe.ui.form.on("Sales Order", {
);
}
frm.doc.items.forEach((item) => {
if (flt(item.stock_reserved_qty) > 0 && frappe.model.can_read("Stock Reservation Entry")) {
frm.add_custom_button(
__("Reserved Stock"),
() => frm.events.show_reserved_stock(frm),
__("Stock Reservation")
);
return;
}
});
if (!frm.doc.is_subcontracted) {
frm.doc.items.forEach((item) => {
if (
flt(item.stock_reserved_qty) > 0 &&
frappe.model.can_read("Stock Reservation Entry")
) {
frm.add_custom_button(
__("Reserved Stock"),
() => frm.events.show_reserved_stock(frm),
__("Stock Reservation")
);
return;
}
});
}
}
if (frm.doc.docstatus === 0) {
@@ -112,7 +119,7 @@ frappe.ui.form.on("Sales Order", {
frm.events.get_items_from_internal_purchase_order(frm);
}
if (frm.doc.docstatus === 0) {
if (frm.doc.docstatus === 0 && !frm.doc.is_subcontracted) {
frappe.call({
method: "erpnext.selling.doctype.sales_order.sales_order.get_stock_reservation_status",
callback: function (r) {
@@ -749,10 +756,28 @@ frappe.ui.form.on("Sales Order", {
frm.schedule_dialog.fields_dict.delivery_schedule.refresh();
},
get_subcontracting_boms_for_finished_goods: function (fg_item) {
return frappe.call({
method: "erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_finished_goods",
args: {
fg_items: fg_item,
},
});
},
get_subcontracting_boms_for_service_item: function (service_item) {
return frappe.call({
method: "erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_service_item",
args: {
service_item: service_item,
},
});
},
});
frappe.ui.form.on("Sales Order Item", {
item_code: function (frm, cdt, cdn) {
item_code: async function (frm, cdt, cdn) {
var row = locals[cdt][cdn];
if (frm.doc.delivery_date) {
row.delivery_date = frm.doc.delivery_date;
@@ -760,6 +785,50 @@ frappe.ui.form.on("Sales Order Item", {
} else {
frm.script_manager.copy_from_first_row("items", row, ["delivery_date"]);
}
if (frm.doc.is_subcontracted) {
if (row.item_code && !row.fg_item) {
var result = await frm.events.get_subcontracting_boms_for_service_item(row.item_code);
if (result.message && Object.keys(result.message).length) {
var finished_goods = Object.keys(result.message);
// Set FG if only one active Subcontracting BOM is found
if (finished_goods.length === 1) {
row.fg_item = result.message[finished_goods[0]].finished_good;
row.uom = result.message[finished_goods[0]].finished_good_uom;
refresh_field("items");
} else {
const dialog = new frappe.ui.Dialog({
title: __("Select Finished Good"),
size: "small",
fields: [
{
fieldname: "finished_good",
fieldtype: "Autocomplete",
label: __("Finished Good"),
options: finished_goods,
},
],
primary_action_label: __("Select"),
primary_action: () => {
var subcontracting_bom = result.message[dialog.get_value("finished_good")];
if (subcontracting_bom) {
row.fg_item = subcontracting_bom.finished_good;
row.uom = subcontracting_bom.finished_good_uom;
refresh_field("items");
}
dialog.hide();
},
});
dialog.show();
}
}
}
}
},
delivery_date: function (frm, cdt, cdn) {
@@ -782,6 +851,50 @@ frappe.ui.form.on("Sales Order Item", {
},
});
},
fg_item: async function (frm, cdt, cdn) {
if (frm.doc.is_subcontracted) {
var row = locals[cdt][cdn];
if (row.fg_item) {
var result = await frm.events.get_subcontracting_boms_for_finished_goods(row.fg_item);
if (result.message && Object.keys(result.message).length) {
frappe.model.set_value(cdt, cdn, "item_code", result.message.service_item);
frappe.model.set_value(
cdt,
cdn,
"qty",
flt(row.fg_item_qty) * flt(result.message.conversion_factor)
);
frappe.model.set_value(cdt, cdn, "uom", result.message.service_item_uom);
}
}
}
},
qty: async function (frm, cdt, cdn) {
if (frm.doc.is_subcontracted) {
var row = locals[cdt][cdn];
if (row.fg_item) {
var result = await frm.events.get_subcontracting_boms_for_finished_goods(row.fg_item);
if (
result.message &&
row.item_code == result.message.service_item &&
row.uom == result.message.service_item_uom
) {
frappe.model.set_value(
cdt,
cdn,
"fg_item_qty",
flt(row.qty) / flt(result.message.conversion_factor)
);
}
}
}
},
});
erpnext.selling.SalesOrderController = class SalesOrderController extends erpnext.selling.SellingController {
@@ -795,6 +908,22 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
let allow_delivery = false;
if (doc.docstatus == 1) {
if (
!["Closed", "Completed"].includes(doc.status) &&
flt(doc.per_delivered) < 100 &&
flt(doc.per_billed) < 100
) {
if (!doc.__onload || doc.__onload.can_update_items) {
this.frm.add_custom_button(__("Update Items"), () => {
erpnext.utils.update_child_items({
frm: this.frm,
child_docname: "items",
child_doctype: "Sales Order Detail",
cannot_add_row: false,
});
});
}
}
if (this.frm.has_perm("submit")) {
if (doc.status === "On Hold") {
// un-hold
@@ -847,11 +976,24 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
}
if (doc.is_subcontracted) {
if (!doc.items.every((item) => item.qty == item.subcontracted_qty)) {
this.frm.add_custom_button(
__("Subcontracting Inward Order"),
() => {
me.make_subcontracting_inward_order();
},
__("Create")
);
}
}
if (
(!doc.__onload || !doc.__onload.has_reserved_stock) &&
flt(doc.per_picked) < 100 &&
flt(doc.per_delivered) < 100 &&
frappe.model.can_create("Pick List")
frappe.model.can_create("Pick List") &&
!doc.is_subcontracted
) {
this.frm.add_custom_button(
__("Pick List"),
@@ -880,7 +1022,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
);
}
if (frappe.model.can_create("Work Order")) {
if (frappe.model.can_create("Work Order") && !doc.is_subcontracted) {
this.frm.add_custom_button(
__("Work Order"),
() => this.make_work_order(),
@@ -890,7 +1032,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
// sales invoice
if (flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) {
if (
(flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) ||
doc.is_subcontracted
) {
this.frm.add_custom_button(
__("Sales Invoice"),
() => me.make_sales_invoice(),
@@ -902,13 +1047,16 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
if (
(!doc.order_type ||
((order_is_a_sale || order_is_a_custom_sale) && flt(doc.per_delivered) < 100)) &&
frappe.model.can_create("Material Request")
frappe.model.can_create("Material Request") &&
!doc.is_subcontracted
) {
this.frm.add_custom_button(
__("Material Request"),
() => this.make_material_request(),
__("Create")
);
if (!doc.is_subcontracted) {
this.frm.add_custom_button(
__("Material Request"),
() => this.make_material_request(),
__("Create")
);
}
this.frm.add_custom_button(
__("Request for Raw Materials"),
() => this.make_raw_material_request(),
@@ -917,7 +1065,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
// Make Purchase Order
if (!this.frm.doc.is_internal_customer && frappe.model.can_create("Purchase Order")) {
if (
!this.frm.doc.is_internal_customer &&
frappe.model.can_create("Purchase Order") &&
!doc.is_subcontracted
) {
this.frm.add_custom_button(
__("Purchase Order"),
() => this.make_purchase_order(),
@@ -991,7 +1143,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
}
if (this.frm.doc.docstatus === 0 && frappe.model.can_read("Quotation")) {
if (
this.frm.doc.docstatus === 0 &&
frappe.model.can_read("Quotation") &&
!this.frm.doc.is_subcontracted
) {
this.frm.add_custom_button(
__("Quotation"),
function () {
@@ -1606,6 +1762,14 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
},
});
}
make_subcontracting_inward_order() {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.make_subcontracting_inward_order",
frm: this.frm,
freeze_message: __("Creating Subcontracting Inward Order ..."),
});
}
};
extend_cscript(cur_frm.cscript, new erpnext.selling.SalesOrderController({ frm: cur_frm }));

View File

@@ -25,6 +25,7 @@
"company",
"skip_delivery_note",
"has_unit_price_items",
"is_subcontracted",
"amended_from",
"accounting_dimensions_section",
"cost_center",
@@ -1035,8 +1036,8 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "packed_items",
"depends_on": "packed_items",
"collapsible_depends_on": "eval:!doc.is_subcontracted && doc.packed_items",
"depends_on": "eval:!doc.is_subcontracted && doc.packed_items",
"fieldname": "packing_list",
"fieldtype": "Section Break",
"hide_days": 1,
@@ -1607,7 +1608,7 @@
},
{
"default": "0",
"depends_on": "eval: (doc.docstatus == 0 || doc.reserve_stock)",
"depends_on": "eval: ((doc.docstatus == 0 || doc.reserve_stock) && !doc.is_subcontracted)",
"description": "If checked, Stock will be reserved on <b>Submit</b>",
"fieldname": "reserve_stock",
"fieldtype": "Check",
@@ -1688,13 +1689,21 @@
"fieldtype": "Data",
"is_virtual": 1,
"label": "Last Scanned Warehouse"
},
{
"default": "0",
"fieldname": "is_subcontracted",
"fieldtype": "Check",
"label": "Is Subcontracted",
"print_hide": 1
}
],
"grid_page_length": 50,
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2025-07-28 12:14:29.760988",
"modified": "2025-10-12 12:14:29.760988",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",

View File

@@ -120,6 +120,7 @@ class SalesOrder(SellingController):
incoterm: DF.Link | None
inter_company_order_reference: DF.Link | None
is_internal_customer: DF.Check
is_subcontracted: DF.Check
items: DF.Table[SalesOrderItem]
language: DF.Link | None
letter_head: DF.Link | None
@@ -195,6 +196,10 @@ class SalesOrder(SellingController):
def onload(self) -> None:
super().onload()
if self.get("is_subcontracted"):
self.set_onload("can_update_items", self.can_update_items())
return
if frappe.get_single_value("Stock Settings", "enable_stock_reservation"):
if self.has_unreserved_stock():
self.set_onload("has_unreserved_stock", True)
@@ -202,6 +207,15 @@ class SalesOrder(SellingController):
if has_reserved_stock(self.doctype, self.name):
self.set_onload("has_reserved_stock", True)
def can_update_items(self) -> bool:
result = True
if self.is_subcontracted:
if frappe.db.exists("Subcontracting Inward Order", {"sales_order": self.name, "docstatus": 1}):
result = False
return result
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
@@ -233,6 +247,7 @@ class SalesOrder(SellingController):
make_packing_list(self)
self.validate_with_previous_doc()
self.validate_fg_item_for_subcontracting()
self.set_status()
if not self.billing_status:
@@ -243,7 +258,39 @@ class SalesOrder(SellingController):
self.advance_payment_status = "Not Requested"
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.enable_auto_reserve_stock()
if not self.get("is_subcontracted"):
self.enable_auto_reserve_stock()
def validate_fg_item_for_subcontracting(self):
if self.is_subcontracted:
for item in self.items:
if not item.fg_item:
frappe.throw(
_("Row #{0}: Finished Good Item is not specified for service item {1}").format(
item.idx, item.item_code
)
)
else:
if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"):
frappe.throw(
_("Row #{0}: Finished Good Item {1} must be a sub-contracted item").format(
item.idx, item.fg_item
)
)
if not frappe.db.get_value(
"Subcontracting BOM",
{"finished_good": item.fg_item, "is_active": 1},
"finished_good_bom",
) and not frappe.get_value("Item", item.fg_item, "default_bom"):
frappe.throw(
_("Row #{0}: BOM not found for FG Item {1}").format(item.idx, item.fg_item)
)
if not item.fg_item_qty:
frappe.throw(_("Row #{0}: Finished Good Item Qty can not be zero").format(item.idx))
else:
for item in self.items:
item.set("fg_item", None)
item.set("fg_item_qty", 0)
def enable_auto_reserve_stock(self):
if self.is_new() and frappe.get_single_value("Stock Settings", "auto_reserve_stock"):
@@ -449,7 +496,7 @@ class SalesOrder(SellingController):
update_coupon_code_count(self.coupon_code, "used")
if self.get("reserve_stock"):
if self.get("reserve_stock") and not self.get("is_subcontracted"):
self.create_stock_reservation_entries()
def on_cancel(self):
@@ -535,9 +582,23 @@ class SalesOrder(SellingController):
if status == "Draft" and self.docstatus == 1:
self.check_credit_limit()
self.update_reserved_qty()
self.update_subcontracting_order_status()
self.notify_update()
clear_doctype_notifications(self)
def update_subcontracting_order_status(self):
from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import (
update_subcontracting_inward_order_status as update_scio_status,
)
if self.is_subcontracted:
scio = frappe.get_cached_value(
"Subcontracting Inward Order", {"sales_order": self.name, "docstatus": 1}, "name"
)
if scio:
update_scio_status(scio, "Closed" if self.status == "Closed" else None)
def update_reserved_qty(self, so_item_rows=None):
"""update requested qty (before ordered_qty is updated)"""
item_wh_list = []
@@ -1290,6 +1351,46 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a
child_filter = d.name in filtered_items if filtered_items else True
return child_filter
def add_self_rm(doclist):
parent = frappe.qb.DocType("Subcontracting Inward Order")
child = frappe.qb.DocType("Subcontracting Inward Order Received Item")
query = (
frappe.qb.from_(parent)
.join(child)
.on(parent.name == child.parent)
.select(
child.required_qty,
child.consumed_qty,
(child.billed_qty - child.returned_qty).as_("qty"),
child.rm_item_code,
child.stock_uom,
child.name,
)
.where(
(parent.docstatus == 1)
& (parent.sales_order == source_name)
& (child.is_customer_provided_item == 0)
)
)
result = query.run(as_dict=True)
if result:
idx = len(doclist.items) + 1
for item in result:
if (qty := max(item.required_qty, item.consumed_qty) - item.qty) > 0:
doclist.append(
"items",
{
"item_code": item.rm_item_code,
"qty": qty,
"uom": item.stock_uom,
"scio_detail": item.name,
},
)
doclist.process_item_selection(idx)
idx += 1
doclist.has_subcontracted = 1
doclist = get_mapped_doc(
"Sales Order",
source_name,
@@ -1328,6 +1429,9 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a
ignore_permissions=ignore_permissions,
)
if frappe.get_cached_value("Sales Order", source_name, "is_subcontracted"):
add_self_rm(doclist)
automatically_fetch_payment_terms = cint(
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
@@ -2005,3 +2109,71 @@ def get_work_order_items(sales_order, for_raw_material_request=0):
@frappe.whitelist()
def get_stock_reservation_status():
return frappe.get_single_value("Stock Settings", "enable_stock_reservation")
@frappe.whitelist()
def make_subcontracting_inward_order(source_name, target_doc=None):
if not is_so_fully_subcontracted(source_name):
return get_mapped_subcontracting_inward_order(source_name, target_doc)
else:
frappe.throw(_("This Sales Order has been fully subcontracted."))
def is_so_fully_subcontracted(so_name):
table = frappe.qb.DocType("Sales Order Item")
query = (
frappe.qb.from_(table)
.select(table.name)
.where((table.parent == so_name) & (table.qty != table.subcontracted_qty))
)
return not query.run(as_dict=True)
def get_mapped_subcontracting_inward_order(source_name, target_doc=None):
def post_process(source_doc, target_doc):
if (
frappe.db.count(
"Warehouse", {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0}
)
== 1
):
target_doc.customer_warehouse = frappe.get_cached_value(
"Warehouse",
{"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0},
"name",
)
target_doc.populate_items_table()
if target_doc and isinstance(target_doc, str):
target_doc = json.loads(target_doc)
for key in ["service_items", "items", "received_items"]:
if key in target_doc:
del target_doc[key]
target_doc = json.dumps(target_doc)
target_doc = get_mapped_doc(
"Sales Order",
source_name,
{
"Sales Order": {
"doctype": "Subcontracting Inward Order",
"field_map": {},
"field_no_map": ["total_qty", "total", "net_total"],
"validation": {
"docstatus": ["=", 1],
},
},
"Sales Order Item": {
"doctype": "Subcontracting Inward Order Service Item",
"field_map": {
"name": "sales_order_item",
},
"field_no_map": ["qty", "fg_item_qty", "amount"],
"condition": lambda item: item.qty != item.subcontracted_qty,
},
},
target_doc,
post_process,
)
return target_doc

View File

@@ -30,5 +30,6 @@ def get_data():
{"label": _("Reference"), "items": ["Quotation", "Auto Repeat", "Stock Reservation Entry"]},
{"label": _("Payment"), "items": ["Payment Entry", "Payment Request", "Journal Entry"]},
{"label": _("Schedule"), "items": ["Delivery Schedule Item"]},
{"label": _("Subcontracting Inward"), "items": ["Subcontracting Inward Order"]},
],
}

View File

@@ -2613,6 +2613,7 @@ def make_sales_order(**args):
so.customer = args.customer or "_Test Customer"
so.currency = args.currency or "INR"
so.po_no = args.po_no or ""
so.is_subcontracted = args.is_subcontracted or 0
if args.selling_price_list:
so.selling_price_list = args.selling_price_list

View File

@@ -7,6 +7,8 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"fg_item",
"fg_item_qty",
"item_code",
"customer_item_code",
"ensure_delivery_based_on_produced_serial_no",
@@ -25,6 +27,7 @@
"quantity_and_rate",
"qty",
"stock_uom",
"subcontracted_qty",
"col_break2",
"uom",
"conversion_factor",
@@ -468,6 +471,7 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.delivered_by_supplier==1||doc.supplier",
"depends_on": "eval:!parent.is_subcontracted",
"fieldname": "drop_ship_section",
"fieldtype": "Section Break",
"label": "Drop Ship",
@@ -490,6 +494,7 @@
},
{
"collapsible": 1,
"depends_on": "eval:!parent.is_subcontracted",
"fieldname": "item_weight_details",
"fieldtype": "Section Break",
"label": "Item Weight Details"
@@ -517,6 +522,7 @@
"options": "UOM"
},
{
"depends_on": "eval:!parent.is_subcontracted",
"fieldname": "warehouse_and_reference",
"fieldtype": "Section Break",
"label": "Warehouse and Reference"
@@ -879,7 +885,7 @@
{
"allow_on_submit": 1,
"default": "1",
"depends_on": "eval:doc.is_stock_item",
"depends_on": "eval:(doc.is_stock_item && !parent.is_subcontracted)",
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": "Reserve Stock",
@@ -935,6 +941,7 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:!parent.is_subcontracted",
"fieldname": "available_quantity_section",
"fieldtype": "Section Break",
"label": "Available Quantity"
@@ -977,12 +984,39 @@
"fieldname": "add_schedule",
"fieldtype": "Button",
"label": "Add Schedule"
},
{
"allow_on_submit": 1,
"default": "0",
"depends_on": "eval:parent.is_subcontracted",
"fieldname": "subcontracted_qty",
"fieldtype": "Float",
"label": "Subcontracted Quantity",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
},
{
"depends_on": "eval:parent.is_subcontracted",
"fieldname": "fg_item",
"fieldtype": "Link",
"label": "Finished Good",
"mandatory_depends_on": "eval:parent.is_subcontracted",
"options": "Item"
},
{
"depends_on": "eval:parent.is_subcontracted",
"fieldname": "fg_item_qty",
"fieldtype": "Float",
"label": "Finished Good Qty",
"mandatory_depends_on": "eval:parent.is_subcontracted"
}
],
"grid_page_length": 50,
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-08-21 17:01:54.269105",
"modified": "2025-10-13 10:57:43.378448",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",

View File

@@ -42,6 +42,8 @@ class SalesOrderItem(Document):
discount_percentage: DF.Percent
distributed_discount_amount: DF.Currency
ensure_delivery_based_on_produced_serial_no: DF.Check
fg_item: DF.Link | None
fg_item_qty: DF.Float
grant_commission: DF.Check
gross_profit: DF.Currency
image: DF.Attach | None
@@ -84,6 +86,7 @@ class SalesOrderItem(Document):
stock_reserved_qty: DF.Float
stock_uom: DF.Link | None
stock_uom_rate: DF.Currency
subcontracted_qty: DF.Float
supplier: DF.Link | None
target_warehouse: DF.Link | None
total_weight: DF.Float

View File

@@ -11,6 +11,7 @@
"customer_group",
"column_break_4",
"territory",
"item_price_tab",
"item_price_settings_section",
"selling_price_list",
"maintain_same_rate_action",
@@ -22,6 +23,7 @@
"validate_selling_price",
"editable_bundle_item_rates",
"allow_negative_rates_for_items",
"transaction_tab",
"sales_transactions_settings_section",
"so_required",
"dn_required",
@@ -38,7 +40,12 @@
"allow_zero_qty_in_quotation",
"allow_zero_qty_in_sales_order",
"experimental_section",
"use_legacy_js_reactivity"
"use_legacy_js_reactivity",
"subcontracting_inward_tab",
"section_break_zwh6",
"allow_delivery_of_overproduced_qty",
"column_break_mla9",
"deliver_scrap_items"
],
"fields": [
{
@@ -232,6 +239,44 @@
"fieldtype": "Check",
"label": "Allow Quotation with Zero Quantity"
},
{
"fieldname": "section_break_zwh6",
"fieldtype": "Section Break",
"label": "Subcontracting Inward Settings"
},
{
"default": "0",
"description": "If enabled, system will allow user to deliver the entire quantity of the finished goods produced against the Subcontracting Inward Order. If disabled, system will allow delivery of only the ordered quantity.",
"fieldname": "allow_delivery_of_overproduced_qty",
"fieldtype": "Check",
"label": "Allow Delivery of Overproduced Qty"
},
{
"fieldname": "column_break_mla9",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "If enabled, the Scrap Item generated against a Finished Good will also be added in the Stock Entry when delivering that Finished Good.",
"fieldname": "deliver_scrap_items",
"fieldtype": "Check",
"label": "Deliver Scrap Items"
},
{
"fieldname": "item_price_tab",
"fieldtype": "Tab Break",
"label": "Item Price"
},
{
"fieldname": "transaction_tab",
"fieldtype": "Tab Break",
"label": "Transaction"
},
{
"fieldname": "subcontracting_inward_tab",
"fieldtype": "Tab Break",
"label": "Subcontracting Inward"
},
{
"default": "0",
"fieldname": "fallback_to_default_price_list",
@@ -251,7 +296,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-09-24 16:08:48.865885",
"modified": "2025-10-12 16:08:48.865885",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",

View File

@@ -21,6 +21,7 @@ class SellingSettings(Document):
from frappe.types import DF
allow_against_multiple_purchase_orders: DF.Check
allow_delivery_of_overproduced_qty: DF.Check
allow_multiple_items: DF.Check
allow_negative_rates_for_items: DF.Check
allow_sales_order_creation_for_expired_quotation: DF.Check
@@ -29,6 +30,7 @@ class SellingSettings(Document):
blanket_order_allowance: DF.Float
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
customer_group: DF.Link | None
deliver_scrap_items: DF.Check
dn_required: DF.Literal["No", "Yes"]
dont_reserve_sales_order_qty_on_sales_return: DF.Check
editable_bundle_item_rates: DF.Check

View File

@@ -122,6 +122,30 @@ def install(country=None):
"purpose": "Material Consumption for Manufacture",
"is_standard": 1,
},
{
"doctype": "Stock Entry Type",
"name": _("Receive from Customer"),
"purpose": "Receive from Customer",
"is_standard": 1,
},
{
"doctype": "Stock Entry Type",
"name": _("Return Raw Material to Customer"),
"purpose": "Return Raw Material to Customer",
"is_standard": 1,
},
{
"doctype": "Stock Entry Type",
"name": _("Subcontracting Delivery"),
"purpose": "Subcontracting Delivery",
"is_standard": 1,
},
{
"doctype": "Stock Entry Type",
"name": _("Subcontracting Return"),
"purpose": "Subcontracting Return",
"is_standard": 1,
},
# territory: with two default territories, one for home country and one named Rest of the World
{
"doctype": "Territory",

View File

@@ -221,7 +221,13 @@ frappe.ui.form.on("Item", {
const stock_exists = frm.doc.__onload && frm.doc.__onload.stock_exists ? 1 : 0;
["is_stock_item", "has_serial_no", "has_batch_no", "has_variants"].forEach((fieldname) => {
[
"is_stock_item",
"is_customer_provided_item",
"has_serial_no",
"has_batch_no",
"has_variants",
].forEach((fieldname) => {
frm.set_df_property(fieldname, "read_only", stock_exists);
});

View File

@@ -87,7 +87,6 @@
"lead_time_days",
"last_purchase_rate",
"is_customer_provided_item",
"customer",
"supplier_details",
"delivered_by_supplier",
"column_break2",
@@ -206,9 +205,11 @@
},
{
"default": "0",
"depends_on": "eval:!doc.is_customer_provided_item",
"fieldname": "allow_alternative_item",
"fieldtype": "Check",
"label": "Allow Alternative Item"
"label": "Allow Alternative Item",
"read_only_depends_on": "eval:doc.is_customer_provided_item"
},
{
"allow_in_quick_entry": 1,
@@ -585,13 +586,6 @@
"fieldtype": "Check",
"label": "Is Customer Provided Item"
},
{
"depends_on": "eval:doc.is_customer_provided_item==1",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
},
{
"collapsible": 1,
"depends_on": "eval:!doc.is_fixed_asset",
@@ -771,10 +765,9 @@
},
{
"default": "0",
"description": "If subcontracted to a vendor",
"fieldname": "is_sub_contracted_item",
"fieldtype": "Check",
"label": "Supply Raw Materials for Purchase",
"label": "Is Subcontracted Item",
"oldfieldname": "is_sub_contracted_item",
"oldfieldtype": "Select"
},
@@ -954,7 +947,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2025-10-01 16:58:40.946604",
"modified": "2025-10-12 16:58:40.946604",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -82,7 +82,6 @@ class Item(Document):
country_of_origin: DF.Link | None
create_new_batch: DF.Check
cumulative_time: DF.Int
customer: DF.Link | None
customer_code: DF.SmallText | None
customer_items: DF.Table[ItemCustomerDetail]
customs_tariff_number: DF.Link | None
@@ -965,7 +964,13 @@ class Item(Document):
if self.is_new():
return
restricted_fields = ("has_serial_no", "is_stock_item", "valuation_method", "has_batch_no")
restricted_fields = (
"has_serial_no",
"is_customer_provided_item",
"is_stock_item",
"valuation_method",
"has_batch_no",
)
values = frappe.db.get_value("Item", self.name, restricted_fields, as_dict=True)
if not values:

View File

@@ -67,6 +67,10 @@ class PackedItem(Document):
def make_packing_list(doc):
"Make/Update packing list for Product Bundle Item."
if doc.get("is_subcontracted"):
return
if doc.get("_action") and doc._action == "update_after_submit":
return

View File

@@ -7,6 +7,8 @@ import json
from collections import Counter, defaultdict
import frappe
import frappe.query_builder
import frappe.query_builder.functions
from frappe import _, _dict, bold
from frappe.model.document import Document
from frappe.model.naming import make_autoname
@@ -1916,10 +1918,9 @@ def get_serial_and_batch_ledger(**kwargs):
def get_auto_data(**kwargs):
kwargs = frappe._dict(kwargs)
if cint(kwargs.has_serial_no):
return get_available_serial_nos(kwargs)
return get_serial_nos_from_sre(kwargs) if kwargs.scio_detail else get_available_serial_nos(kwargs)
elif cint(kwargs.has_batch_no):
return get_auto_batch_nos(kwargs)
return get_batch_nos_from_sre(kwargs) if kwargs.scio_detail else get_auto_batch_nos(kwargs)
def get_available_batches_qty(available_batches):
@@ -2021,6 +2022,28 @@ def get_available_serial_nos(kwargs):
)
def get_serial_nos_from_sre(kwargs):
table = frappe.qb.DocType("Stock Reservation Entry")
child_table = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(table)
.join(child_table)
.on(table.name == child_table.parent)
.select(child_table.serial_no, child_table.batch_no, child_table.warehouse)
.where(
(table.docstatus == 1)
& (table.voucher_detail_no == kwargs.scio_detail)
& (child_table.qty != child_table.delivered_qty)
)
.limit(cint(kwargs.qty) or 10000000)
)
if kwargs.based_on == "LIFO":
query = query.orderby(child_table.creation, order=frappe.query_builder.Order.desc)
else:
query = query.orderby(child_table.creation)
return query.run(as_dict=True)
def get_non_expired_batches(batches):
filters = {}
if isinstance(batches, list):
@@ -2094,13 +2117,13 @@ def get_bundle_wise_serial_nos(data, kwargs):
def get_reserved_voucher_details(kwargs):
reserved_voucher_details = []
value = {
"Delivery Note": ["Delivery Note Item", "against_sales_order"],
"Stock Entry": ["Stock Entry", "work_order"],
"Work Order": ["Work Order", "production_plan"],
field_mapper = {
"Delivery Note": [["Delivery Note Item", "against_sales_order"]],
"Stock Entry": [["Stock Entry", "work_order"], ["Stock Entry", "subcontracting_inward_order"]],
"Work Order": [["Work Order", "production_plan"], ["Work Order", "subcontracting_inward_order"]],
}.get(kwargs.get("sabb_voucher_type"))
if not value or not kwargs.get("sabb_voucher_no"):
if not field_mapper or not kwargs.get("sabb_voucher_no"):
return reserved_voucher_details
voucher_based_filters = {
@@ -2119,11 +2142,15 @@ def get_reserved_voucher_details(kwargs):
},
}.get(kwargs.get("sabb_voucher_type"))
reserved_voucher_details = frappe.get_all(
value[0],
pluck=value[1],
filters=voucher_based_filters,
)
reserved_voucher_details = []
for row in field_mapper:
reserved_voucher_details.extend(
frappe.get_all(
row[0],
pluck=row[1],
filters=voucher_based_filters,
)
)
return reserved_voucher_details
@@ -2429,6 +2456,43 @@ def get_auto_batch_nos(kwargs):
return get_qty_based_available_batches(available_batches, qty)
def get_batch_nos_from_sre(kwargs):
from frappe.query_builder.functions import Max, Min, Sum
table = frappe.qb.DocType("Stock Reservation Entry")
child_table = frappe.qb.DocType("Serial and Batch Entry")
if kwargs.based_on == "LIFO":
creation_field = Max(child_table.creation).as_("sort_creation")
order = frappe.query_builder.Order.desc
else:
creation_field = Min(child_table.creation).as_("sort_creation")
order = frappe.query_builder.Order.asc
query = (
frappe.qb.from_(table)
.join(child_table)
.on(table.name == child_table.parent)
.select(
child_table.batch_no,
child_table.warehouse,
Sum(child_table.qty - child_table.delivered_qty).as_("qty"),
creation_field,
)
.where(
(table.docstatus == 1)
& (table.voucher_detail_no == kwargs.scio_detail)
& (child_table.qty != child_table.delivered_qty)
)
.groupby(child_table.batch_no, child_table.warehouse)
.orderby("sort_creation", order=order)
.orderby(child_table.batch_no, order=frappe.query_builder.Order.asc)
)
result = query.run(as_dict=True)
return get_qty_based_available_batches(result, flt(kwargs.qty)) if flt(kwargs.qty) else result
def get_batches_to_be_considered(sales_order_name):
parent = frappe.qb.DocType("Stock Reservation Entry")
child = frappe.qb.DocType("Serial and Batch Entry")

View File

@@ -87,15 +87,13 @@ frappe.ui.form.on("Stock Entry", {
frappe.throw(__("Please enter Item Code to get Batch Number"));
} else {
if (
in_list(
[
"Material Transfer for Manufacture",
"Manufacture",
"Repack",
"Send to Subcontractor",
],
doc.purpose
)
[
"Material Transfer for Manufacture",
"Manufacture",
"Repack",
"Send to Subcontractor",
"Receive from Customer",
].includes(doc.purpose)
) {
filters = {
item_code: item.item_code,
@@ -214,7 +212,7 @@ frappe.ui.form.on("Stock Entry", {
refresh: function (frm) {
frm.trigger("get_items_from_transit_entry");
if (!frm.doc.docstatus) {
if (!frm.doc.docstatus && !frm.doc.subcontracting_inward_order) {
frm.trigger("validate_purpose_consumption");
frm.add_custom_button(
__("Material Request"),
@@ -299,7 +297,7 @@ frappe.ui.form.on("Stock Entry", {
}
}
if (frm.doc.docstatus === 0) {
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
frm.add_custom_button(
__("Purchase Invoice"),
function () {
@@ -367,7 +365,11 @@ frappe.ui.form.on("Stock Entry", {
);
}
if (frm.doc.docstatus === 0 && frm.doc.purpose == "Material Issue") {
if (
frm.doc.docstatus === 0 &&
frm.doc.purpose == "Material Issue" &&
!frm.doc.subcontracting_inward_order
) {
frm.add_custom_button(
__("Expired Batches"),
function () {
@@ -420,6 +422,17 @@ frappe.ui.form.on("Stock Entry", {
}
frm.events.set_route_options_for_new_doc(frm);
frm.set_df_property(
"items",
"cannot_add_rows",
frm.doc.subcontracting_inward_order &&
[
"Return Raw Material to Customer",
"Subcontracting Return",
"Subcontracting Delivery",
].includes(frm.doc.purpose)
);
},
set_route_options_for_new_doc(frm) {
@@ -445,7 +458,7 @@ frappe.ui.form.on("Stock Entry", {
},
get_items_from_transit_entry: function (frm) {
if (frm.doc.docstatus === 0) {
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
frm.add_custom_button(
__("Transit Entry"),
function () {
@@ -609,7 +622,8 @@ frappe.ui.form.on("Stock Entry", {
frm.doc.docstatus === 0 &&
["Material Issue", "Material Receipt", "Material Transfer", "Send to Subcontractor"].includes(
frm.doc.purpose
)
) &&
!frm.doc.subcontracting_inward_order
) {
frm.add_custom_button(
__("Bill of Materials"),
@@ -622,10 +636,6 @@ frappe.ui.form.on("Stock Entry", {
},
get_items_from_bom: function (frm) {
let filters = function () {
return { filters: { docstatus: 1 } };
};
let fields = [
{
fieldname: "bom",
@@ -1084,10 +1094,28 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
this.frm.add_fetch("purchase_order", "supplier", "supplier");
} else {
this.frm.add_fetch("subcontracting_order", "supplier", "supplier");
this.frm.add_fetch("subcontracting_inward_order", "customer", "customer");
}
frappe.dynamic_link = { doc: this.frm.doc, fieldname: "supplier", doctype: "Supplier" };
this.frm.set_query("supplier_address", erpnext.queries.address_query);
const operator = this.frm.doc.subcontracting_inward_order ? "in" : "not in";
this.frm.set_query("stock_entry_type", function () {
return {
filters: {
purpose: [
operator,
[
"Receive from Customer",
"Return Raw Material to Customer",
"Subcontracting Delivery",
"Subcontracting Return",
],
],
},
};
});
}
onload_post_render() {
@@ -1100,7 +1128,6 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
}
refresh() {
var me = this;
erpnext.toggle_naming_series();
this.toggle_related_fields(this.frm.doc);
this.toggle_enable_bom();
@@ -1369,6 +1396,8 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
doc.delivery_note_no =
doc.sales_invoice_no =
null;
} else if (doc.purpose === "Receive from Customer") {
doc.supplier = doc.supplier_name = doc.supplier_address = doc.purchase_receipt_no = null;
} else {
doc.customer =
doc.customer_name =

View File

@@ -17,6 +17,7 @@
"job_card",
"purchase_order",
"subcontracting_order",
"subcontracting_inward_order",
"delivery_note_no",
"sales_invoice_no",
"pick_list",
@@ -129,7 +130,7 @@
"label": "Purpose",
"oldfieldname": "purpose",
"oldfieldtype": "Select",
"options": "Material Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble",
"options": "Material Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble\nReceive from Customer\nReturn Raw Material to Customer\nSubcontracting Delivery\nSubcontracting Return",
"read_only": 1,
"search_index": 1
},
@@ -708,8 +709,17 @@
"fieldtype": "Check",
"label": "Is Additional Transfer Entry",
"read_only": 1
},
{
"depends_on": "subcontracting_inward_order",
"fieldname": "subcontracting_inward_order",
"fieldtype": "Link",
"label": "Subcontracting Inward Order",
"options": "Subcontracting Inward Order",
"read_only": 1
}
],
"grid_page_length": 50,
"icon": "fa fa-file-text",
"idx": 1,
"index_web_pages_for_search": 1,

View File

@@ -2,7 +2,6 @@
# License: GNU General Public License v3. See license.txt
import copy
import json
from collections import defaultdict
@@ -79,11 +78,12 @@ class MaxSampleAlreadyRetainedError(frappe.ValidationError):
from erpnext.controllers.stock_controller import StockController
from erpnext.controllers.subcontracting_inward_controller import SubcontractingInwardController
form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"}
class StockEntry(StockController):
class StockEntry(StockController, SubcontractingInwardController):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -138,6 +138,10 @@ class StockEntry(StockController):
"Repack",
"Send to Subcontractor",
"Disassemble",
"Receive from Customer",
"Return Raw Material to Customer",
"Subcontracting Delivery",
"Subcontracting Return",
]
remarks: DF.Text | None
sales_invoice_no: DF.Link | None
@@ -147,6 +151,7 @@ class StockEntry(StockController):
source_address_display: DF.TextEditor | None
source_warehouse_address: DF.Link | None
stock_entry_type: DF.Link
subcontracting_inward_order: DF.Link | None
subcontracting_order: DF.Link | None
supplier: DF.Link | None
supplier_address: DF.Link | None
@@ -174,6 +179,15 @@ class StockEntry(StockController):
"order_supplied_items_field": "Purchase Order Item Supplied",
}
)
elif self.subcontracting_inward_order:
self.subcontract_data = frappe._dict(
{
"order_doctype": "Subcontracting Inward Order",
"order_field": "subcontracting_inward_order",
"rm_detail_field": "scio_detail",
"order_received_items_field": "Subcontracting Inward Order Received Item",
}
)
else:
self.subcontract_data = frappe._dict(
{
@@ -248,15 +262,20 @@ class StockEntry(StockController):
self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
def on_submit(self):
self.validate_closed_subcontracting_order()
self.validate_subcontract_order()
super().validate_subcontracting_inward()
def on_submit(self):
self.make_bundle_using_old_serial_batch_fields()
self.update_work_order()
self.update_disassembled_order()
self.adjust_stock_reservation_entries_for_return()
self.update_sre_for_subcontracting_delivery()
self.update_stock_ledger()
self.make_stock_reserve_for_wip_and_fg()
self.validate_subcontract_order()
self.update_subcontract_order_supplied_items()
self.update_subcontracting_order_status()
self.update_pick_list_status()
@@ -273,6 +292,8 @@ class StockEntry(StockController):
if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
self.set_material_request_transfer_status("Completed")
super().on_submit_subcontracting_inward()
def on_cancel(self):
self.delink_asset_repair_sabb()
self.validate_closed_subcontracting_order()
@@ -284,7 +305,8 @@ class StockEntry(StockController):
self.validate_work_order_status()
self.update_work_order()
self.update_disassembled_order(is_cancel=True)
self.update_disassembled_order()
self.cancel_stock_reservation_entries_for_inward()
self.update_stock_ledger()
self.ignore_linked_doctypes = (
@@ -299,6 +321,8 @@ class StockEntry(StockController):
self.update_cost_in_project()
self.update_transferred_qty()
self.update_quality_inspection()
self.adjust_stock_reservation_entries_for_return()
self.update_sre_for_subcontracting_delivery()
self.delete_auto_created_batches()
self.delete_linked_stock_entry()
@@ -307,6 +331,8 @@ class StockEntry(StockController):
if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
self.set_material_request_transfer_status("In Transit")
super().on_cancel_subcontracting_inward()
def on_update(self):
super().on_update()
self.set_serial_and_batch_bundle()
@@ -369,11 +395,17 @@ class StockEntry(StockController):
"Send to Subcontractor",
"Material Consumption for Manufacture",
"Disassemble",
"Receive from Customer",
"Return Raw Material to Customer",
"Subcontracting Delivery",
"Subcontracting Return",
]
if self.purpose not in valid_purposes:
frappe.throw(_("Purpose must be one of {0}").format(comma_or(valid_purposes)))
super().validate_purpose()
def delete_linked_stock_entry(self):
if self.purpose == "Send to Warehouse":
for d in frappe.get_all(
@@ -507,6 +539,9 @@ class StockEntry(StockController):
flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item)
)
if self.purpose == "Subcontracting Delivery":
item.expense_account = frappe.get_value("Company", self.company, "default_expense_account")
def validate_fg_completed_qty(self):
if self.purpose != "Manufacture":
return
@@ -576,7 +611,10 @@ class StockEntry(StockController):
title=_("Difference Account in Items Table"),
)
if self.purpose != "Material Issue" and acc_details.account_type == "Cost of Goods Sold":
if (
self.purpose not in ["Material Issue", "Subcontracting Delivery"]
and acc_details.account_type == "Cost of Goods Sold"
):
frappe.msgprint(
_(
"At row #{0}: you have selected the Difference Account {1}, which is a Cost of Goods Sold type account. Please select a different account"
@@ -595,6 +633,8 @@ class StockEntry(StockController):
"Send to Subcontractor",
"Material Transfer for Manufacture",
"Material Consumption for Manufacture",
"Return Raw Material to Customer",
"Subcontracting Delivery",
]
target_mandatory = [
@@ -602,6 +642,8 @@ class StockEntry(StockController):
"Material Transfer",
"Send to Subcontractor",
"Material Transfer for Manufacture",
"Receive from Customer",
"Subcontracting Return",
]
validate_for_manufacture = any([d.bom_no for d in self.get("items")])
@@ -862,7 +904,7 @@ class StockEntry(StockController):
if d.s_warehouse or d.set_basic_rate_manually:
continue
if d.allow_zero_valuation_rate:
if d.allow_zero_valuation_rate and self.purpose != "Receive from Customer":
d.basic_rate = 0.0
items.append(d.item_code)
@@ -1086,13 +1128,15 @@ class StockEntry(StockController):
self.purpose = frappe.get_cached_value("Stock Entry Type", self.stock_entry_type, "purpose")
def make_serial_and_batch_bundle_for_outward(self):
if self.docstatus == 0:
return
serial_or_batch_items = get_serial_or_batch_items(self.items)
if not serial_or_batch_items:
return
serial_nos, batch_nos = self.set_serial_batch_fields_for_subcontracting_inward()
if self.docstatus == 0:
return
already_picked_serial_nos = []
for row in self.items:
@@ -1118,7 +1162,9 @@ class StockEntry(StockController):
"ignore_serial_nos": already_picked_serial_nos,
"qty": row.transfer_qty * -1,
}
).update_serial_and_batch_entries()
).update_serial_and_batch_entries(
serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name)
)
elif not row.serial_and_batch_bundle:
bundle_doc = SerialBatchCreation(
{
@@ -1133,7 +1179,9 @@ class StockEntry(StockController):
"company": self.company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle()
).make_serial_and_batch_bundle(
serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name)
)
if not bundle_doc:
continue
@@ -1146,6 +1194,32 @@ class StockEntry(StockController):
row.serial_and_batch_bundle = bundle_doc.name
def set_serial_batch_fields_for_subcontracting_inward(self):
serial_nos, batch_nos = frappe._dict(), frappe._dict()
for row in self.items:
if self.purpose in [
"Return Raw Material to Customer",
"Subcontracting Delivery",
"Subcontracting Return",
]:
if not row.serial_and_batch_bundle:
serial_nos_list, batch_nos_list = self.get_serial_nos_and_batches_from_sres(
row.scio_detail, only_pending=self.purpose != "Subcontracting Return"
)
if len(batch_nos_list) > 1:
row.use_serial_batch_fields = 0
if row.use_serial_batch_fields:
if serial_nos_list and not row.serial_no:
row.serial_no = "\n".join(serial_nos_list)
if batch_nos_list and not row.batch_no:
row.batch_no = next(iter(batch_nos_list.keys()))
serial_nos[row.name], batch_nos[row.name] = serial_nos_list, batch_nos_list
return serial_nos, batch_nos
def validate_subcontract_order(self):
"""Throw exception if more raw material is transferred against Subcontract Order than in
the raw materials supplied table"""
@@ -1329,8 +1403,12 @@ class StockEntry(StockController):
)
def validate_closed_subcontracting_order(self):
if self.get("subcontracting_order"):
check_on_hold_or_closed_status("Subcontracting Order", self.subcontracting_order)
order = self.get("subcontracting_order") or self.get("subcontracting_inward_order")
if order:
check_on_hold_or_closed_status(
"Subcontracting Order" if self.get("subcontracting_order") else "Subcontracting Inward Order",
order,
)
def mark_finished_and_scrap_items(self):
if self.purpose != "Repack" and any(
@@ -1740,12 +1818,12 @@ class StockEntry(StockController):
if not pro_doc.operations:
pro_doc.set_actual_dates()
def update_disassembled_order(self, is_cancel=False):
def update_disassembled_order(self):
if not self.work_order:
return
if self.purpose == "Disassemble" and self.fg_completed_qty:
pro_doc = frappe.get_doc("Work Order", self.work_order)
pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, is_cancel)
pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, self._action == "cancel")
def make_stock_reserve_for_wip_and_fg(self):
if self.is_stock_reserve_for_work_order():
@@ -1754,6 +1832,7 @@ class StockEntry(StockController):
self.purpose == "Manufacture"
and not pro_doc.sales_order
and not pro_doc.production_plan_sub_assembly_item
and not pro_doc.subcontracting_inward_order
):
return
@@ -1774,7 +1853,7 @@ class StockEntry(StockController):
def is_stock_reserve_for_work_order(self):
if (
self.work_order
and self.stock_entry_type in ["Material Transfer for Manufacture", "Manufacture"]
and self.purpose in ["Material Transfer for Manufacture", "Manufacture"]
and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock")
):
return True
@@ -2828,7 +2907,8 @@ class StockEntry(StockController):
child_qty = flt(item_row["qty"], precision)
if not self.is_return and child_qty <= 0 and not item_row.get("is_scrap_item"):
continue
if self.purpose != "Receive from Customer":
continue
se_child = self.append("items")
stock_uom = item_row.get("stock_uom") or frappe.db.get_value("Item", d, "stock_uom")
@@ -2837,7 +2917,7 @@ class StockEntry(StockController):
se_child.item_code = item_row.get("item_code") or cstr(d)
se_child.uom = item_row["uom"] if item_row.get("uom") else stock_uom
se_child.stock_uom = stock_uom
se_child.qty = child_qty
se_child.qty = child_qty if child_qty > 0 else 0
se_child.allow_alternative_item = item_row.get("allow_alternative_item", 0)
se_child.subcontracted_item = item_row.get("main_item_code")
se_child.cost_center = item_row.get("cost_center") or get_default_cost_center(
@@ -2847,6 +2927,7 @@ class StockEntry(StockController):
se_child.is_scrap_item = item_row.get("is_scrap_item", 0)
se_child.po_detail = item_row.get("po_detail")
se_child.sco_rm_detail = item_row.get("sco_rm_detail")
se_child.scio_detail = item_row.get("scio_detail")
for field in [
self.subcontract_data.rm_detail_field,

View File

@@ -38,6 +38,7 @@
"sample_quantity",
"rates_section",
"basic_rate",
"customer_provided_item_cost",
"additional_cost",
"landed_cost_voucher_amount",
"valuation_rate",
@@ -75,6 +76,7 @@
"ste_detail",
"po_detail",
"sco_rm_detail",
"scio_detail",
"putaway_rule",
"column_break_51",
"reference_purchase_receipt",
@@ -97,7 +99,8 @@
"label": "Source Warehouse",
"oldfieldname": "s_warehouse",
"oldfieldtype": "Link",
"options": "Warehouse"
"options": "Warehouse",
"read_only_depends_on": "eval:in_list([\"Subcontracting Delivery\", \"Return Raw Material to Customer\"], parent.purpose)"
},
{
"fieldname": "col_break1",
@@ -557,7 +560,8 @@
"default": "0",
"fieldname": "is_finished_item",
"fieldtype": "Check",
"label": "Is Finished Item"
"label": "Is Finished Item",
"read_only_depends_on": "eval:in_list([\"Subcontracting Delivery\", \"Subcontracting Return\"], parent.purpose)"
},
{
"fieldname": "job_card_item",
@@ -614,6 +618,26 @@
"label": "Landed Cost Voucher Amount",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "eval:parent.purpose == \"Receive from Customer\"",
"fieldname": "customer_provided_item_cost",
"fieldtype": "Currency",
"label": "Customer Provided Item Cost",
"no_copy": 1,
"non_negative": 1,
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "scio_detail",
"fieldtype": "Data",
"hidden": 1,
"label": "SCIO Detail",
"print_hide": 1,
"read_only": 1,
"search_index": 1
}
],
"grid_page_length": 50,
@@ -621,7 +645,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-09 10:24:34.717676",
"modified": "2025-10-13 12:13:43.389334",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",

View File

@@ -27,6 +27,7 @@ class StockEntryDetail(Document):
bom_no: DF.Link | None
conversion_factor: DF.Float
cost_center: DF.Link | None
customer_provided_item_cost: DF.Currency
description: DF.TextEditor | None
expense_account: DF.Link | None
has_item_scanned: DF.Check
@@ -53,6 +54,7 @@ class StockEntryDetail(Document):
retain_sample: DF.Check
s_warehouse: DF.Link | None
sample_quantity: DF.Int
scio_detail: DF.Data | None
sco_rm_detail: DF.Data | None
serial_and_batch_bundle: DF.Link | None
serial_no: DF.Text | None

View File

@@ -17,7 +17,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Purpose",
"options": "\nMaterial Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble",
"options": "Material Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble\nReceive from Customer\nReturn Raw Material to Customer\nSubcontracting Delivery\nSubcontracting Return",
"reqd": 1,
"set_only_once": 1
},
@@ -36,8 +36,9 @@
"read_only": 1
}
],
"grid_page_length": 50,
"links": [],
"modified": "2025-01-15 16:00:22.696958",
"modified": "2025-09-04 13:03:31.283348",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Type",
@@ -86,9 +87,10 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"track_changes": 1,
"translated_doctype": 1
}
}

View File

@@ -21,7 +21,6 @@ class StockEntryType(Document):
add_to_transit: DF.Check
is_standard: DF.Check
purpose: DF.Literal[
"",
"Material Issue",
"Material Receipt",
"Material Transfer",
@@ -31,6 +30,10 @@ class StockEntryType(Document):
"Repack",
"Send to Subcontractor",
"Disassemble",
"Receive from Customer",
"Return Raw Material to Customer",
"Subcontracting Delivery",
"Subcontracting Return",
]
# end: auto-generated types
@@ -50,6 +53,10 @@ class StockEntryType(Document):
"Repack",
"Send to Subcontractor",
"Disassemble",
"Receive from Customer",
"Return Raw Material to Customer",
"Subcontracting Delivery",
"Subcontracting Return",
]:
frappe.throw(f"Stock Entry Type {self.name} cannot be set as standard")

View File

@@ -84,7 +84,7 @@
"no_copy": 1,
"oldfieldname": "voucher_type",
"oldfieldtype": "Data",
"options": "\nSales Order\nWork Order\nProduction Plan",
"options": "\nSales Order\nWork Order\nSubcontracting Inward Order\nProduction Plan",
"print_width": "150px",
"read_only": 1,
"width": "150px"
@@ -288,7 +288,7 @@
"fieldtype": "Select",
"label": "From Voucher Type",
"no_copy": 1,
"options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order\nProduction Plan",
"options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order\nProduction Plan\nSubcontracting Inward Order",
"print_hide": 1,
"read_only": 1,
"report_hide": 1
@@ -344,7 +344,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-08-25 19:48:33.170835",
"modified": "2025-10-12 19:48:33.170835",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reservation Entry",

View File

@@ -33,7 +33,13 @@ class StockReservationEntry(Document):
from_voucher_detail_no: DF.Data | None
from_voucher_no: DF.DynamicLink | None
from_voucher_type: DF.Literal[
"", "Pick List", "Purchase Receipt", "Stock Entry", "Work Order", "Production Plan"
"",
"Pick List",
"Purchase Receipt",
"Stock Entry",
"Work Order",
"Production Plan",
"Subcontracting Inward Order",
]
has_batch_no: DF.Check
has_serial_no: DF.Check
@@ -57,7 +63,9 @@ class StockReservationEntry(Document):
voucher_detail_no: DF.Data | None
voucher_no: DF.DynamicLink | None
voucher_qty: DF.Float
voucher_type: DF.Literal["", "Sales Order", "Work Order", "Production Plan"]
voucher_type: DF.Literal[
"", "Sales Order", "Work Order", "Subcontracting Inward Order", "Production Plan"
]
warehouse: DF.Link | None
# end: auto-generated types
@@ -236,12 +244,11 @@ class StockReservationEntry(Document):
def validate_reservation_based_on_qty(self) -> None:
"""Validates `Reserved Qty` when `Reservation Based On` is `Qty`."""
if self.reservation_based_on == "Qty":
if self.reservation_based_on == "Qty" and self.voucher_type != "Subcontracting Inward Order":
self.validate_with_allowed_qty(self.reserved_qty)
def auto_reserve_serial_and_batch(self, based_on: str | None = None) -> None:
"""Auto pick Serial and Batch Nos to reserve when `Reservation Based On` is `Serial and Batch`."""
if (
not self.from_voucher_type
and (self.get("_action") == "submit")
@@ -409,7 +416,8 @@ class StockReservationEntry(Document):
frappe.throw(msg)
# Should be called after validating Serial and Batch Nos.
self.validate_with_allowed_qty(qty_to_be_reserved)
if self.voucher_type != "Subcontracting Inward Order":
self.validate_with_allowed_qty(qty_to_be_reserved)
self.db_set("reserved_qty", qty_to_be_reserved)
def update_reserved_qty_in_voucher(
@@ -1215,15 +1223,25 @@ class StockReservation:
return available_qty
def transfer_reservation_entries_to(self, docnames, from_doctype, to_doctype):
def transfer_reservation_entries_to(
self, docnames, from_doctype, to_doctype, against_fg_item=None, qty_change=None
):
if isinstance(docnames, str):
docnames = [docnames]
items_to_reserve = self.get_items_to_reserve(docnames, from_doctype, to_doctype)
if qty_change:
for key, value in qty_change.items():
row = next((item for item in items_to_reserve if item.voucher_detail_no == key), None)
if row:
row.qty += value
row.required_qty += value
if not items_to_reserve:
return
reservation_entries = self.get_reserved_entries(from_doctype, docnames)
reservation_entries = self.get_reserved_entries(from_doctype, docnames, against_fg_item)
if not reservation_entries:
return
@@ -1386,7 +1404,7 @@ class StockReservation:
sre.save()
sre.submit()
def get_reserved_entries(self, doctype, docnames):
def get_reserved_entries(self, doctype, docnames, against_fg_item=None):
if isinstance(docnames, str):
docnames = [docnames]
@@ -1426,6 +1444,17 @@ class StockReservation:
.orderby(sabb_entry.idx)
)
if against_fg_item:
query = query.where(
sre.voucher_detail_no.isin(
frappe.get_all(
"Subcontracting Inward Order Received Item",
{"reference_name": against_fg_item, "docstatus": 1},
pluck="name",
)
)
)
return query.run(as_dict=True)
def get_items_to_reserve(self, docnames, from_doctype, to_doctype):

View File

@@ -18,6 +18,7 @@
"column_break_4",
"account",
"company",
"customer",
"address_and_contact",
"address_html",
"column_break_10",
@@ -258,6 +259,15 @@
"fieldname": "is_rejected_warehouse",
"fieldtype": "Check",
"label": "Is Rejected Warehouse"
},
{
"allow_in_quick_entry": 1,
"depends_on": "eval:!doc.disabled",
"description": "Only to be used for Subcontracting Inward.",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
}
],
"grid_page_length": 50,
@@ -265,7 +275,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2025-06-26 11:19:04.673115",
"modified": "2025-09-05 14:47:17.140099",
"modified_by": "Administrator",
"module": "Stock",
"name": "Warehouse",

View File

@@ -29,6 +29,7 @@ class Warehouse(NestedSet):
address_line_2: DF.Data | None
city: DF.Data | None
company: DF.Link
customer: DF.Link | None
default_in_transit_warehouse: DF.Link | None
disabled: DF.Check
email_id: DF.Data | None

View File

@@ -337,7 +337,7 @@ def validate_item_details(ctx: ItemDetailsCtx, item):
throw(_(msg), title=_("Template Item Selected"))
elif ctx.transaction_type == "buying" and ctx.doctype != "Material Request":
elif ctx.doctype != "Material Request":
if ctx.is_subcontracted:
if ctx.is_old_subcontracting_flow:
if item.is_sub_contracted_item != 1:

View File

@@ -1026,13 +1026,23 @@ class SerialBatchCreation:
for d in remove_list:
package.remove(d)
def make_serial_and_batch_bundle(self):
def make_serial_and_batch_bundle(
self, serial_nos=None, batch_nos=None
): # passing None instead of [] due to ruff linter error B006
serial_nos = serial_nos or []
batch_nos = batch_nos or []
doc = frappe.new_doc("Serial and Batch Bundle")
valid_columns = doc.meta.get_valid_columns()
for key, value in self.__dict__.items():
if key in valid_columns:
doc.set(key, value)
if serial_nos:
self.serial_nos = serial_nos
if batch_nos:
self.batches = batch_nos
if self.type_of_transaction == "Outward":
self.set_auto_serial_batch_entries_for_outward()
elif self.type_of_transaction == "Inward":
@@ -1081,10 +1091,21 @@ class SerialBatchCreation:
self.batch_no = batches[0]
self.serial_nos = self.get_auto_created_serial_nos()
def update_serial_and_batch_entries(self):
def update_serial_and_batch_entries(
self, serial_nos=None, batch_nos=None
): # passing None instead of [] due to ruff linter error B006
serial_nos = serial_nos or []
batch_nos = batch_nos or []
doc = frappe.get_doc("Serial and Batch Bundle", self.serial_and_batch_bundle)
doc.type_of_transaction = self.type_of_transaction
doc.set("entries", [])
if serial_nos:
self.serial_nos = serial_nos
if batch_nos:
self.batch_nos = batch_nos
self.set_auto_serial_batch_entries_for_outward()
self.set_serial_batch_entries(doc)
if not doc.get("entries"):
@@ -1429,3 +1450,28 @@ def get_batchwise_qty(voucher_type, voucher_no):
return frappe._dict({})
return frappe._dict(batches)
def get_serial_batch_list_from_item(item):
serial_list, batch_list = [], []
if item.serial_and_batch_bundle:
table = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(table)
.select(table.serial_no, table.batch_no)
.where(table.parent == item.serial_and_batch_bundle)
)
result = query.run(as_dict=True)
for row in result:
if row.serial_no and row.serial_no not in serial_list:
serial_list.append(row.serial_no)
if row.batch_no and row.batch_no not in batch_list:
batch_list.append(row.batch_no)
else:
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
serial_list = get_serial_nos(item.serial_no) if item.serial_no else []
batch_list = [item.batch_no] if item.batch_no else []
return serial_list, batch_list

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,
)