Compare commits

...

4 Commits

27 changed files with 920 additions and 17 deletions

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import importlib
import frappe
from frappe import _, throw
@@ -11,6 +12,9 @@ import erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_var
from erpnext.buying.doctype.supplier_scorecard_criteria.supplier_scorecard_criteria import (
get_variables,
)
from erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_variable import (
VariablePathNotFound,
)
class SupplierScorecardPeriod(Document):
@@ -119,11 +123,30 @@ class SupplierScorecardPeriod(Document):
def import_string_path(path):
components = path.split(".")
mod = __import__(components[0])
for comp in components[1:]:
mod = getattr(mod, comp)
return mod
app_name = path.split(".", 1)[0]
if app_name not in frappe.get_installed_apps():
throw(_("App {0} is not installed").format(app_name), frappe.AppNotInstalledError)
target, attributes = import_longest_module(path)
for attribute in attributes:
if not hasattr(target, attribute):
throw(_("Could not find path for {0}").format(path), VariablePathNotFound)
target = getattr(target, attribute)
return target
def import_longest_module(path):
parts = path.split(".")
module = importlib.import_module(parts[0])
for index in range(1, len(parts)):
module_name = ".".join(parts[: index + 1])
try:
module = importlib.import_module(module_name)
except ModuleNotFoundError as error:
if error.name != module_name:
raise
return module, parts[index:]
return module, []
def make_supplier_scorecard(source_name, target_doc=None):

View File

@@ -1,10 +1,31 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
import frappe
from erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_variable import (
VariablePathNotFound,
)
from erpnext.tests.utils import ERPNextTestSuite
CUSTOM_APP = "custom_scorecard_app"
CUSTOM_VARIABLES_SOURCE = """
def get_value(scorecard):
return 7
class Metrics:
@staticmethod
def get_value(scorecard):
return 7
"""
class TestSupplierScorecardPeriod(ERPNextTestSuite):
def test_criteria_score_is_clamped_to_bounds(self):
@@ -55,6 +76,60 @@ class TestSupplierScorecardPeriod(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, period.validate_criteria_weights)
def test_custom_variable_path_in_unimported_module(self):
for attribute in ("get_value", "Metrics.get_value"):
with self.subTest(attribute=attribute):
path = f"{CUSTOM_APP}.variables.{attribute}"
variable = make_variable(path)
period = make_period(
variables=[{"variable_label": "Custom", "param_name": "custom", "path": path}]
)
with unimported_custom_app():
variable.validate_path_exists()
with unimported_custom_app():
period.calculate_variables()
self.assertEqual(period.variables[0].value, 7)
def test_variable_path_outside_installed_apps_is_rejected(self):
period = make_period(variables=[{"variable_label": "OS", "param_name": "os", "path": "os.getcwd"}])
self.assertRaises(frappe.AppNotInstalledError, period.calculate_variables)
def test_missing_variable_path_is_rejected(self):
for path in ("erpnext.no_such_module.get_value", f"{CUSTOM_APP}.variables.missing"):
with self.subTest(path=path):
variable = make_variable(path)
with unimported_custom_app():
self.assertRaises(VariablePathNotFound, variable.validate_path_exists)
def test_variable_module_import_error_is_not_hidden(self):
variable = make_variable(f"{CUSTOM_APP}.broken.get_value")
with unimported_custom_app():
self.assertRaises(ModuleNotFoundError, variable.validate_path_exists)
@contextmanager
def unimported_custom_app():
with tempfile.TemporaryDirectory() as directory:
package = Path(directory, CUSTOM_APP)
package.mkdir()
(package / "__init__.py").touch()
(package / "variables.py").write_text(CUSTOM_VARIABLES_SOURCE)
(package / "broken.py").write_text("import scorecard_missing_dependency\n")
installed_apps = [*frappe.get_installed_apps(), CUSTOM_APP]
with (
patch.object(sys, "path", [directory, *sys.path]),
patch.dict(sys.modules),
patch.object(frappe, "get_installed_apps", return_value=installed_apps),
):
yield
def make_variable(path):
return frappe.get_doc({"doctype": "Supplier Scorecard Variable", "path": path})
def make_period(variables=None, criteria=None):
period = frappe.new_doc("Supplier Scorecard Period")

View File

@@ -36,14 +36,11 @@ class SupplierScorecardVariable(Document):
def validate_path_exists(self):
if "." in self.path:
try:
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
import_string_path,
)
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
import_string_path,
)
import_string_path(self.path)
except AttributeError:
frappe.throw(_("Could not find path for {0}").format(self.path), VariablePathNotFound)
import_string_path(self.path)
else:
if not hasattr(sys.modules[__name__], self.path):

View File

@@ -43,5 +43,6 @@ import "./financial_statements.js";
import "./sales_trends_filters.js";
import "./purchase_trends_filters.js";
import "./stock_balance_report.js";
import "./subcontracting_inward_report_filters.js";
// import { sum } from 'frappe/public/utils/util.js'

View File

@@ -0,0 +1,32 @@
erpnext.get_subcontracting_inward_report_filters = function () {
return [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "from_date",
label: __("From Date"),
fieldtype: "Date",
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
reqd: 1,
},
{
fieldname: "to_date",
label: __("To Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
reqd: 1,
},
{
fieldname: "customer",
label: __("Customer"),
fieldtype: "Link",
options: "Customer",
},
];
};

View File

@@ -737,6 +737,30 @@ class TestInventoryDimension(ERPNextTestSuite):
dn_doc.save()
self.assertRaises(InventoryDimensionNegativeStockError, dn_doc.submit)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_validate_negative_stock_for_multiple_rows_in_single_voucher(self):
item_code = "Test Negative Inventory Dimension Multi Row Item"
create_item(item_code)
inv_dimension = create_inventory_dimension(
apply_to_all_doctypes=1,
dimension_name="Inv Site",
reference_document="Inv Site",
document_type="Inv Site",
validate_negative_stock=1,
)
inv_dimension.db_set("validate_negative_stock", 1)
for site in ["Site 1", "Site 2"]:
pr_doc = make_purchase_receipt(item_code=item_code, qty=100, do_not_submit=True)
pr_doc.items[0].inv_site = site
pr_doc.submit()
dn_doc = create_delivery_note(item_code=item_code, qty=60, do_not_submit=True)
dn_doc.items[0].inv_site = "Site 1"
dn_doc.append("items", dn_doc.items[0].as_dict(no_default_fields=True, no_child_table_fields=True))
self.assertRaises(InventoryDimensionNegativeStockError, dn_doc.submit)
def get_voucher_sl_entries(voucher_no, fields):
return frappe.get_all(

View File

@@ -133,7 +133,7 @@ class StockLedgerEntry(Document):
.where(
(sle.item_code == self.item_code)
& (sle.warehouse == self.warehouse)
& (sle.posting_datetime < self.posting_datetime)
& (sle.posting_datetime <= self.posting_datetime)
& (sle.company == self.company)
& (sle.is_cancelled == 0)
)

View File

@@ -323,13 +323,13 @@ class SubcontractingInwardOrder(SubcontractingController):
["process_loss_qty", "include_exploded_items"],
as_dict=True,
)
stock_qty = frappe.get_value(
qty_consumed_per_unit = frappe.get_value(
"BOM Explosion Item" if data.include_exploded_items else "BOM Item",
{"name": rm_item.bom_detail_no},
"stock_qty",
"qty_consumed_per_unit",
)
qty = flt(
stock_qty * data.process_loss_qty,
qty_consumed_per_unit * 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

View File

@@ -334,6 +334,36 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite):
self.assertEqual(scio.items[0].delivered_qty, 2)
self.assertEqual(scio.items[0].returned_qty, 1)
def test_process_loss_receipt_qty_for_multi_unit_bom(self):
new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001"))
new_bom.quantity = 2
for item in new_bom.items:
item.qty = 2
new_bom.submit()
sc_bom = frappe.get_doc("Subcontracting BOM", {"finished_good": "Basic FG Item"})
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()
manufacture = frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture"))
manufacture.save()
manufacture.process_loss_qty = 1
manufacture.items[-1].qty = 4
manufacture.submit()
scio.reload()
rm_in = scio.make_rm_stock_entry_inward()
for item in rm_in.get("items"):
self.assertEqual(item.qty, 1)
def test_manufacture_consumption_validates_against_work_order(self):
"""Cover the non-skip-transfer manufacture path, where consumption is validated
against the Work Order's transferred quantity (the Work Order branch of

View File

@@ -0,0 +1,3 @@
frappe.query_reports["Subcontracted Items To Be Delivered"] = {
filters: erpnext.get_subcontracting_inward_report_filters(),
};

View File

@@ -0,0 +1,35 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-24 12:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"json": "",
"letter_head": null,
"modified": "2026-09-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracted Items To Be Delivered",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Subcontracting Inward Order",
"report_name": "Subcontracted Items To Be Delivered",
"report_type": "Script Report",
"roles": [
{
"role": "Stock User"
},
{
"role": "Sales Manager"
},
{
"role": "Sales User"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,43 @@
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.subcontracting.report.utils import get_inward_order_columns, get_open_inward_order_rows
def execute(filters=None):
return get_columns(), get_data(filters)
def get_data(filters):
rows = get_open_inward_order_rows(
filters,
"items",
["item_code", "item_name", "stock_uom", "qty", "produced_qty", "delivered_qty"],
[],
)
precision = frappe.get_precision("Subcontracting Inward Order Item", "qty")
for row in rows:
row.pending_qty = flt(row.qty - row.delivered_qty, precision)
return [row for row in rows if row.pending_qty > 0]
def get_columns():
return [
*get_inward_order_columns(),
{
"label": _("Finished Good"),
"fieldname": "item_code",
"fieldtype": "Link",
"options": "Item",
"width": 150,
},
{"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 150},
{"label": _("UOM"), "fieldname": "stock_uom", "fieldtype": "Link", "options": "UOM", "width": 80},
{"label": _("Order Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 110},
{"label": _("Produced Qty"), "fieldname": "produced_qty", "fieldtype": "Float", "width": 110},
{"label": _("Delivered Qty"), "fieldname": "delivered_qty", "fieldtype": "Float", "width": 110},
{"label": _("Pending Qty"), "fieldname": "pending_qty", "fieldtype": "Float", "width": 110},
]

View File

@@ -0,0 +1,82 @@
import frappe
from frappe.utils import today
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as make_stock_entry_from_wo
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.subcontracting.doctype.subcontracting_inward_order.test_subcontracting_inward_order import (
create_so_scio,
create_test_data,
)
from erpnext.subcontracting.report.subcontracted_items_to_be_delivered.subcontracted_items_to_be_delivered import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestSubcontractedItemsToBeDelivered(ERPNextTestSuite):
def setUp(self):
create_test_data()
make_stock_entry(
item_code="Self RM", qty=100, to_warehouse="Stores - _TC", purpose="Material Receipt"
)
def test_pending_qty_for_partial_delivery(self):
_so, scio = create_so_scio()
produce_and_deliver(scio, 2)
rows = get_report_rows(scio)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].item_code, "Basic FG Item")
self.assertEqual(rows[0].qty, 5)
self.assertEqual(rows[0].produced_qty, 5)
self.assertEqual(rows[0].delivered_qty, 2)
self.assertEqual(rows[0].pending_qty, 3)
def test_fully_delivered_item_is_excluded(self):
_so, scio = create_so_scio()
produce_and_deliver(scio, 5)
self.assertEqual(get_report_rows(scio), [])
def test_customer_return_does_not_reopen_pending_qty(self):
_so, scio = create_so_scio()
produce_and_deliver(scio, 2)
return_finished_good(scio, 1)
rows = get_report_rows(scio)
self.assertEqual(rows[0].delivered_qty, 2)
self.assertEqual(rows[0].pending_qty, 3)
def produce_and_deliver(scio, qty):
frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit()
scio.reload()
work_order = frappe.get_doc("Work Order", scio.make_work_order()[0])
work_order.skip_transfer = 1
work_order.required_items[-1].source_warehouse = "Stores - _TC"
work_order.submit()
frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(work_order.name, "Manufacture")).submit()
scio.reload()
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
delivery.items[0].qty = qty
delivery.submit()
scio.reload()
def return_finished_good(scio, qty):
fg_return = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_return())
fg_return.items[0].qty = qty
fg_return.items[0].t_warehouse = "_Test Warehouse - _TC"
fg_return.submit()
scio.reload()
def get_report_rows(scio):
_columns, data = execute(
frappe._dict(company=scio.company, from_date=today(), to_date=today(), customer=scio.customer)
)
return [row for row in data if row.subcontracting_inward_order == scio.name]

View File

@@ -0,0 +1,3 @@
frappe.query_reports["Subcontracted Raw Materials To Be Received"] = {
filters: erpnext.get_subcontracting_inward_report_filters(),
};

View File

@@ -0,0 +1,35 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-24 12:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"json": "",
"letter_head": null,
"modified": "2026-09-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracted Raw Materials To Be Received",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Subcontracting Inward Order",
"report_name": "Subcontracted Raw Materials To Be Received",
"report_type": "Script Report",
"roles": [
{
"role": "Stock User"
},
{
"role": "Sales Manager"
},
{
"role": "Sales User"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,90 @@
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.subcontracting.report.utils import get_inward_order_columns, get_open_inward_order_rows
def execute(filters=None):
return get_columns(), get_data(filters)
def get_data(filters):
rows = get_open_inward_order_rows(
filters,
"received_items",
[
"reference_name",
"main_item_code",
"rm_item_code",
"stock_uom",
"required_qty",
"received_qty",
"returned_qty",
],
[
["per_produced", "<", 100],
["Subcontracting Inward Order Received Item", "is_customer_provided_item", "=", 1],
],
)
set_pending_qty(rows)
return [row for row in rows if row.pending_qty > 0]
def set_pending_qty(rows):
finished_goods = get_finished_goods({row.reference_name for row in rows})
precision = frappe.get_precision("Subcontracting Inward Order Received Item", "required_qty")
for row in rows:
finished_good = finished_goods[row.reference_name]
row.process_loss_qty = flt(
row.required_qty / finished_good.qty * finished_good.process_loss_qty, precision
)
row.pending_qty = flt(
row.required_qty - row.received_qty + row.returned_qty + row.process_loss_qty, precision
)
def get_finished_goods(order_items):
if not order_items:
return {}
return {
row.name: row
for row in frappe.get_all(
"Subcontracting Inward Order Item",
filters={"name": ["in", list(order_items)]},
fields=["name", "qty", "process_loss_qty"],
)
}
def get_columns():
return [
*get_inward_order_columns(),
{
"label": _("Finished Good"),
"fieldname": "main_item_code",
"fieldtype": "Link",
"options": "Item",
"width": 150,
},
{
"label": _("Raw Material"),
"fieldname": "rm_item_code",
"fieldtype": "Link",
"options": "Item",
"width": 150,
},
{"label": _("UOM"), "fieldname": "stock_uom", "fieldtype": "Link", "options": "UOM", "width": 80},
{"label": _("Required Qty"), "fieldname": "required_qty", "fieldtype": "Float", "width": 110},
{"label": _("Received Qty"), "fieldname": "received_qty", "fieldtype": "Float", "width": 110},
{"label": _("Returned Qty"), "fieldname": "returned_qty", "fieldtype": "Float", "width": 110},
{
"label": _("Process Loss Qty"),
"fieldname": "process_loss_qty",
"fieldtype": "Float",
"width": 130,
},
{"label": _("Pending Qty"), "fieldname": "pending_qty", "fieldtype": "Float", "width": 110},
]

View File

@@ -0,0 +1,95 @@
import frappe
from frappe.utils import today
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as make_stock_entry_from_wo
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.subcontracting.doctype.subcontracting_inward_order.test_subcontracting_inward_order import (
create_so_scio,
create_test_data,
)
from erpnext.subcontracting.report.subcontracted_raw_materials_to_be_received.subcontracted_raw_materials_to_be_received import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestSubcontractedRawMaterialsToBeReceived(ERPNextTestSuite):
def setUp(self):
create_test_data()
make_stock_entry(
item_code="Self RM", qty=100, to_warehouse="Stores - _TC", purpose="Material Receipt"
)
def test_pending_qty_counts_returned_raw_materials(self):
_so, scio = create_so_scio()
receive_basic_rm(scio, 2)
return_basic_rm(scio, 1)
rows = get_report_rows(scio)
self.assertNotIn("Self RM", rows)
self.assertEqual(rows["Basic RM"].required_qty, 5)
self.assertEqual(rows["Basic RM"].received_qty, 2)
self.assertEqual(rows["Basic RM"].returned_qty, 1)
self.assertEqual(rows["Basic RM"].pending_qty, 4)
self.assertEqual(rows["RM with Batch"].pending_qty, 5)
def test_fully_received_raw_material_is_excluded(self):
_so, scio = create_so_scio()
receive_basic_rm(scio, 5)
rows = get_report_rows(scio)
self.assertNotIn("Basic RM", rows)
self.assertIn("RM with Batch", rows)
def test_pending_qty_includes_raw_materials_for_process_loss(self):
_so, scio = create_so_scio()
frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward()).submit()
scio.reload()
manufacture_with_process_loss(scio, 1)
rows = get_report_rows(scio)
self.assertEqual(rows["Basic RM"].received_qty, 5)
self.assertEqual(rows["Basic RM"].process_loss_qty, 1)
self.assertEqual(rows["Basic RM"].pending_qty, 1)
def receive_basic_rm(scio, qty):
rm_in = frappe.new_doc("Stock Entry").update(scio.make_rm_stock_entry_inward())
rm_in.items = [item for item in rm_in.items if item.item_code == "Basic RM"]
rm_in.items[0].qty = qty
rm_in.submit()
scio.reload()
def return_basic_rm(scio, qty):
rm_return = frappe.new_doc("Stock Entry").update(scio.make_rm_return())
rm_return.items = [item for item in rm_return.items if item.item_code == "Basic RM"]
rm_return.items[0].qty = qty
rm_return.submit()
scio.reload()
def manufacture_with_process_loss(scio, process_loss_qty):
work_order = frappe.get_doc("Work Order", scio.make_work_order()[0])
work_order.skip_transfer = 1
work_order.required_items[-1].source_warehouse = "Stores - _TC"
work_order.submit()
manufacture = frappe.new_doc("Stock Entry").update(
make_stock_entry_from_wo(work_order.name, "Manufacture")
)
manufacture.save()
manufacture.process_loss_qty = process_loss_qty
manufacture.items[-1].qty = work_order.qty - process_loss_qty
manufacture.submit()
scio.reload()
def get_report_rows(scio):
_columns, data = execute(
frappe._dict(company=scio.company, from_date=today(), to_date=today(), customer=scio.customer)
)
return {row.rm_item_code: row for row in data if row.subcontracting_inward_order == scio.name}

View File

@@ -0,0 +1,27 @@
frappe.query_reports["Subcontracting Inward Order Summary"] = {
filters: [
...erpnext.get_subcontracting_inward_report_filters(),
{
fieldname: "subcontracting_inward_order",
label: __("Subcontracting Inward Order"),
fieldtype: "Link",
options: "Subcontracting Inward Order",
get_query: () => {
const report = frappe.query_report;
const filters = {
docstatus: 1,
company: report.get_filter_value("company"),
transaction_date: [
"between",
[report.get_filter_value("from_date"), report.get_filter_value("to_date")],
],
};
if (report.get_filter_value("customer")) {
filters.customer = report.get_filter_value("customer");
}
return { filters };
},
},
],
};

View File

@@ -0,0 +1,35 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-24 12:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"json": "",
"letter_head": null,
"modified": "2026-09-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Subcontracting Inward Order",
"report_name": "Subcontracting Inward Order Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Stock User"
},
{
"role": "Sales Manager"
},
{
"role": "Sales User"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,118 @@
import frappe
from frappe import _
from erpnext.subcontracting.report.utils import get_inward_order_columns, get_inward_order_filters
def execute(filters=None):
return get_columns(), get_data(filters)
def get_data(filters):
finished_goods = get_finished_goods(filters)
raw_materials = get_raw_materials({row.subcontracting_inward_order for row in finished_goods})
data = []
for finished_good in finished_goods:
data.extend(get_finished_good_rows(finished_good, raw_materials.get(finished_good.order_item, [])))
return data
def get_finished_goods(filters):
order_filters = get_inward_order_filters(filters)
if filters.get("subcontracting_inward_order"):
order_filters.append(["name", "=", filters.subcontracting_inward_order])
return frappe.get_list(
"Subcontracting Inward Order",
fields=[
"name as subcontracting_inward_order",
"transaction_date",
"customer",
"status",
"items.name as order_item",
"items.item_code",
"items.stock_uom",
"items.qty",
"items.produced_qty",
"items.delivered_qty",
"items.returned_qty",
],
filters=order_filters,
order_by="transaction_date, name, items.idx",
)
def get_raw_materials(orders):
if not orders:
return {}
raw_materials = {}
for row in frappe.get_all(
"Subcontracting Inward Order Received Item",
fields=[
"reference_name",
"rm_item_code",
"stock_uom as rm_stock_uom",
"required_qty",
"received_qty",
"consumed_qty",
"returned_qty as rm_returned_qty",
],
filters={"parent": ["in", list(orders)], "is_customer_provided_item": 1},
order_by="idx",
):
raw_materials.setdefault(row.reference_name, []).append(row)
return raw_materials
def get_finished_good_rows(finished_good, raw_materials):
rows = []
for index, raw_material in enumerate(raw_materials or [{}]):
finished_good_columns = finished_good if index == 0 else dict.fromkeys(finished_good)
rows.append({**finished_good_columns, **raw_material})
return rows
def get_columns():
return [
*get_inward_order_columns(),
{"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 100},
{
"label": _("Finished Good"),
"fieldname": "item_code",
"fieldtype": "Link",
"options": "Item",
"width": 150,
},
{"label": _("UOM"), "fieldname": "stock_uom", "fieldtype": "Link", "options": "UOM", "width": 80},
{"label": _("Order Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 100},
{"label": _("Produced Qty"), "fieldname": "produced_qty", "fieldtype": "Float", "width": 110},
{"label": _("Delivered Qty"), "fieldname": "delivered_qty", "fieldtype": "Float", "width": 110},
{
"label": _("Returned by Customer"),
"fieldname": "returned_qty",
"fieldtype": "Float",
"width": 150,
},
{
"label": _("Raw Material"),
"fieldname": "rm_item_code",
"fieldtype": "Link",
"options": "Item",
"width": 150,
},
{"label": _("UOM"), "fieldname": "rm_stock_uom", "fieldtype": "Link", "options": "UOM", "width": 80},
{"label": _("Required Qty"), "fieldname": "required_qty", "fieldtype": "Float", "width": 110},
{"label": _("Received Qty"), "fieldname": "received_qty", "fieldtype": "Float", "width": 110},
{"label": _("Consumed Qty"), "fieldname": "consumed_qty", "fieldtype": "Float", "width": 110},
{
"label": _("Returned to Customer"),
"fieldname": "rm_returned_qty",
"fieldtype": "Float",
"width": 150,
},
]

View File

@@ -0,0 +1,47 @@
import frappe
from frappe.utils import today
from erpnext.subcontracting.doctype.subcontracting_inward_order.test_subcontracting_inward_order import (
create_so_scio,
create_test_data,
)
from erpnext.subcontracting.report.subcontracted_raw_materials_to_be_received.test_subcontracted_raw_materials_to_be_received import (
receive_basic_rm,
return_basic_rm,
)
from erpnext.subcontracting.report.subcontracting_inward_order_summary.subcontracting_inward_order_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestSubcontractingInwardOrderSummary(ERPNextTestSuite):
def setUp(self):
create_test_data()
def test_finished_good_is_listed_with_customer_provided_raw_materials(self):
_so, scio = create_so_scio()
receive_basic_rm(scio, 2)
return_basic_rm(scio, 1)
_columns, data = execute(
frappe._dict(
company=scio.company,
from_date=today(),
to_date=today(),
subcontracting_inward_order=scio.name,
)
)
self.assertEqual(
[row["rm_item_code"] for row in data],
["Basic RM", "RM with Serial", "RM with Batch", "RM with Serial and Batch"],
)
self.assertEqual(data[0]["subcontracting_inward_order"], scio.name)
self.assertEqual(data[0]["item_code"], "Basic FG Item")
self.assertEqual(data[0]["qty"], 5)
self.assertEqual(data[0]["required_qty"], 5)
self.assertEqual(data[0]["received_qty"], 2)
self.assertEqual(data[0]["rm_returned_qty"], 1)
self.assertIsNone(data[1]["item_code"])
self.assertIsNone(data[1]["qty"])

View File

@@ -0,0 +1,48 @@
import frappe
from frappe import _
def get_inward_order_filters(filters):
order_filters = [
["docstatus", "=", 1],
["company", "=", filters.company],
["transaction_date", "between", [filters.from_date, filters.to_date]],
]
if filters.get("customer"):
order_filters.append(["customer", "=", filters.customer])
return order_filters
def get_open_inward_order_rows(filters, table_fieldname, fields, extra_filters):
return frappe.get_list(
"Subcontracting Inward Order",
fields=[
"name as subcontracting_inward_order",
"transaction_date",
"customer",
*[f"{table_fieldname}.{field}" for field in fields],
],
filters=[*get_inward_order_filters(filters), ["status", "!=", "Closed"], *extra_filters],
order_by=f"transaction_date, name, {table_fieldname}.idx",
)
def get_inward_order_columns():
return [
{
"label": _("Subcontracting Inward Order"),
"fieldname": "subcontracting_inward_order",
"fieldtype": "Link",
"options": "Subcontracting Inward Order",
"width": 180,
},
{"label": _("Date"), "fieldname": "transaction_date", "fieldtype": "Date", "width": 100},
{
"label": _("Customer"),
"fieldname": "customer",
"fieldtype": "Link",
"options": "Customer",
"width": 150,
},
]

View File

@@ -65,9 +65,69 @@
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 0,
"collapsible": 1,
"hidden": 0,
"icon": "sheet",
"indent": 1,
"is_default_module": 0,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Inward Order Summary",
"link_to": "Subcontracting Inward Order Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Raw Materials To Be Received",
"link_to": "Subcontracted Raw Materials To Be Received",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"added": 0,
"child": 1,
"collapsible": 1,
"hidden": 0,
"indent": 0,
"is_default_module": 0,
"keep_closed": 0,
"label": "Items To Be Delivered",
"link_to": "Subcontracted Items To Be Delivered",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-08-16 00:00:00.000000",
"modified": "2026-09-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting",