Compare commits

..

10 Commits

Author SHA1 Message Date
Mihir Kandoi
ab34dc9025 test(selling): Proforma Invoice lines validated on direct insert 2026-09-24 22:06:50 +05:30
Mihir Kandoi
e19c3c6984 fix(selling): validate Proforma Invoice lines in the controller
The Sales Order check and the line rate and amount lived only in
make_proforma_invoice, so a proforma inserted through the REST API could
be submitted against a cancelled Sales Order, with lines from another
order or a stale amount. validate() now enforces them for every path.
2026-09-24 22:05:42 +05:30
Mihir Kandoi
defff46d8f test(selling): Update Items keeps a row with an issued proforma 2026-09-24 21:57:29 +05:30
Mihir Kandoi
7dd3598156 fix(selling): block deleting a proformed Sales Order row via Update Items
Deleting the row left the issued proforma pointing at a Sales Order
line that no longer exists.
2026-09-24 21:57:13 +05:30
Mihir Kandoi
6bf5fa51d5 test(selling): edited and default Proforma Invoice line description 2026-09-24 21:56:26 +05:30
Mihir Kandoi
d29ccfb569 feat(selling): editable item description on Proforma Invoice
The create dialog now shows each line's Sales Order description for
editing. The proforma line stores it, falling back to the Sales Order
description, and the print shows it under the item name.
2026-09-24 21:55:43 +05:30
Mihir Kandoi
c1808c9124 fix(selling): tidy the Proforma Invoice print
Print the customer address under the customer name, and drop the
Against Sales Order row and the not-a-demand-for-payment footer.
2026-09-24 21:52:26 +05:30
Mihir Kandoi
fa9d7a75e1 fix(selling): keep the Proforma tab on a cancelled Sales Order
Cancelling a Sales Order first needs its proformas cancelled, and the
tab then vanished, hiding the cancelled proformas that the list keeps
for audit. Show the list read-only instead.
2026-09-24 21:51:26 +05:30
Mihir Kandoi
02854a48ee test(selling): reject an amended Proforma Invoice 2026-09-24 21:50:45 +05:30
Mihir Kandoi
b03e48f453 fix(selling): block amending a cancelled Proforma Invoice
A proforma is only created from its Sales Order, but a cancelled one
showed Amend to Administrator, and the amended copy could be saved
outside that path.
2026-09-24 21:50:30 +05:30
35 changed files with 198 additions and 982 deletions

View File

@@ -458,6 +458,12 @@ def validate_child_on_delete(row, parent, ordered_item=None) -> None:
"Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order."
).format(row.idx, row.item_code)
)
if frappe.db.exists("Proforma Invoice Item", {"so_detail": row.name, "docstatus": 1}):
frappe.throw(
_("Row #{0}: Cannot delete item {1} which has an issued Proforma Invoice.").format(
row.idx, row.item_code
)
)
if parent.doctype == "Purchase Order" and flt(row.received_qty):
frappe.throw(

View File

@@ -1,7 +1,6 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import importlib
import frappe
from frappe import _, throw
@@ -12,9 +11,6 @@ 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):
@@ -123,30 +119,11 @@ class SupplierScorecardPeriod(Document):
def import_string_path(path):
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, []
components = path.split(".")
mod = __import__(components[0])
for comp in components[1:]:
mod = getattr(mod, comp)
return mod
def make_supplier_scorecard(source_name, target_doc=None):

View File

@@ -1,31 +1,10 @@
# 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):
@@ -76,60 +55,6 @@ 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,11 +36,14 @@ class SupplierScorecardVariable(Document):
def validate_path_exists(self):
if "." in self.path:
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
import_string_path,
)
try:
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
import_string_path,
)
import_string_path(self.path)
import_string_path(self.path)
except AttributeError:
frappe.throw(_("Could not find path for {0}").format(self.path), VariablePathNotFound)
else:
if not hasattr(sys.modules[__name__], self.path):

View File

@@ -43,6 +43,5 @@ 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

@@ -4,19 +4,21 @@
frappe.ui.form.on("Sales Order", {
refresh(frm) {
erpnext.proforma.toggle_tab(frm, false);
if (frm.doc.docstatus !== 1) return;
if (frm.doc.docstatus === 0) return;
frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => {
if (!enabled) return;
// Defer so the button lands after the standard Create options, not before them.
setTimeout(() => {
frm.add_custom_button(
__("Proforma Invoice"),
() => erpnext.proforma.open_dialog(frm),
__("Create")
);
}, 0);
if (frm.doc.docstatus === 1) {
// Defer so the button lands after the standard Create options, not before them.
setTimeout(() => {
frm.add_custom_button(
__("Proforma Invoice"),
() => erpnext.proforma.open_dialog(frm),
__("Create")
);
}, 0);
}
erpnext.proforma.render_list(frm);
});
},
@@ -117,6 +119,12 @@ Object.assign(erpnext.proforma, {
read_only: 1,
in_list_view: 1,
},
{
fieldname: "description",
fieldtype: "Text Editor",
label: __("Description"),
in_list_view: 1,
},
{
fieldname: "qty",
fieldtype: "Float",
@@ -205,11 +213,12 @@ Object.assign(erpnext.proforma, {
const by_amount = values.based_on === "Amount";
const items = (values.items || [])
.filter((row) => flt(by_amount ? row.amount : row.qty) > 0)
.map((row) =>
by_amount
? { so_detail: row.so_detail, qty: row.qty, amount: row.amount }
: { so_detail: row.so_detail, qty: row.qty }
);
.map((row) => ({
so_detail: row.so_detail,
description: row.description,
qty: row.qty,
amount: row.amount,
}));
if (!items.length) {
frappe.msgprint(__("Please enter a quantity or amount for at least one item."));
@@ -314,6 +323,7 @@ Object.assign(erpnext.proforma, {
],
});
list.refresh();
if (frm.doc.docstatus !== 1) return;
frappe.ui
.button({

View File

@@ -1,32 +0,0 @@
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

@@ -1,8 +1,8 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Proforma Invoice", {
// refresh(frm) {
// },
// });
frappe.ui.form.on("Proforma Invoice", {
refresh(frm) {
frm.page.btn_primary.toggle(frm.doc.docstatus !== 2);
},
});

View File

@@ -45,8 +45,59 @@ class ProformaInvoice(Document):
def validate(self) -> None:
validate_feature_enabled()
self.validate_amended_doc()
self.validate_sales_order()
self.set_item_values()
self.set_total_qty()
def validate_sales_order(self) -> None:
if frappe.db.get_value("Sales Order", self.sales_order, "docstatus") != 1:
frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
def set_item_values(self) -> None:
"""Copy each line's item details from its Sales Order line, then set the rate and amount."""
so_items = {
row.name: row
for row in frappe.get_all(
"Sales Order Item",
filters={"parent": self.sales_order, "parenttype": "Sales Order"},
fields=["name", "item_code", "item_name", "description", "uom", "rate"],
)
}
for item in self.items:
so_item = so_items.get(item.so_detail)
if not so_item:
frappe.throw(
_("Row #{0}: The line does not belong to Sales Order {1}").format(
item.idx, frappe.bold(self.sales_order)
)
)
item.item_code = so_item.item_code
item.item_name = so_item.item_name
item.uom = so_item.uom
item.description = item.description or so_item.description
self.set_rate_and_amount(item, so_item.rate)
def set_rate_and_amount(self, item, sales_order_rate: float) -> None:
"""Quantity basis bills at the Sales Order rate; Amount basis derives the rate from the amount."""
if flt(item.qty) <= 0:
frappe.throw(_("Row #{0}: Qty must be a positive number").format(item.idx))
if self.based_on == "Amount":
if flt(item.amount) <= 0:
frappe.throw(_("Row #{0}: Amount must be a positive number").format(item.idx))
item.rate = flt(item.amount) / flt(item.qty)
else:
item.rate = sales_order_rate
item.amount = flt(item.qty) * flt(sales_order_rate)
def validate_amended_doc(self) -> None:
if self.amended_from:
frappe.throw(
_("Cannot amend {0} {1}, please create a new one instead.").format(
self.doctype, frappe.bold(self.amended_from)
)
)
def before_submit(self) -> None:
self.status = "Issued"
@@ -80,6 +131,7 @@ class ProformaInvoice(Document):
for item in sales_order.items:
item.qty = lines[item.name].qty
item.rate = lines[item.name].rate
item.description = lines[item.name].description
item.discount_amount = 0
item.discount_percentage = 0
sales_order.run_method("calculate_taxes_and_totals")
@@ -116,6 +168,7 @@ def get_sales_order_items(sales_order: str) -> list[dict]:
{
"item_code": item.item_code,
"item_name": item.item_name,
"description": item.description,
"uom": item.uom,
"so_detail": item.name,
"qty": flt(item.qty),
@@ -155,19 +208,13 @@ def make_proforma_invoice(
print_format: str | None = None,
letter_head: str | None = None,
) -> str:
"""The sole creation path for a Proforma Invoice (the doctype is `in_create`).
"""Create and submit a Proforma Invoice from the Sales Order dialog.
`based_on` decides what the user edited per line: "Quantity" (rate fixed, amount = qty x rate)
or "Amount" (both qty and amount entered, rate derived). `hide_item_qty` (Amount basis only)
hides the qty and rate on the printed proforma for a clean value-based document.
"""
validate_feature_enabled()
selected = frappe.parse_json(items)
sales_order_doc = frappe.get_doc("Sales Order", sales_order)
if sales_order_doc.docstatus != 1:
frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
so_items = {item.name: item for item in sales_order_doc.items}
proforma = frappe.new_doc("Proforma Invoice")
proforma.sales_order = sales_order
proforma.based_on = based_on
@@ -179,13 +226,16 @@ def make_proforma_invoice(
)
proforma.letter_head = letter_head
for row in selected:
so_item = so_items.get(row.get("so_detail"))
if not so_item:
continue
line = _proforma_line(so_item, based_on, row)
if line:
proforma.append("items", line)
for row in frappe.parse_json(items):
proforma.append(
"items",
{
"so_detail": row.get("so_detail"),
"qty": row.get("qty"),
"amount": row.get("amount"),
"description": row.get("description"),
},
)
if not proforma.items:
frappe.throw(_("Please enter a quantity or amount for at least one item."))
@@ -195,32 +245,6 @@ def make_proforma_invoice(
return proforma.name
def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
if based_on == "Amount":
# Amount basis: both qty and amount are user-entered; the rate is derived.
qty = flt(row.get("qty"))
amount = flt(row.get("amount"))
if amount <= 0 or qty <= 0:
return None
rate = amount / qty
else:
qty = flt(row.get("qty"))
if qty <= 0:
return None
rate = flt(so_item.rate)
amount = qty * rate
return {
"item_code": so_item.item_code,
"item_name": so_item.item_name,
"uom": so_item.uom,
"qty": qty,
"rate": rate,
"amount": amount,
"so_detail": so_item.name,
}
@frappe.whitelist()
def send_proforma_email(proforma_name: str, recipients: str) -> None:
proforma = frappe.get_doc("Proforma Invoice", proforma_name)

View File

@@ -6,6 +6,7 @@ import json
import frappe
from frappe.utils import flt
from erpnext.accounts.services.child_item_update import update_child_qty_rate
from erpnext.selling.doctype.proforma_invoice.proforma_invoice import (
get_sales_order_items,
make_proforma_invoice,
@@ -24,6 +25,9 @@ class TestProformaInvoice(ERPNextTestSuite):
name = make_proforma_invoice(sales_order.name, json.dumps(items), **kwargs)
return frappe.get_doc("Proforma Invoice", name)
def make_draft_proforma(self, sales_order, **item):
return frappe.new_doc("Proforma Invoice", sales_order=sales_order.name, items=[item]).insert()
def test_partial_proforma_is_non_blocking(self):
"""A proforma must not touch delivery/billing or the source Sales Order."""
sales_order = make_sales_order(qty=10)
@@ -172,6 +176,74 @@ class TestProformaInvoice(ERPNextTestSuite):
("Proforma Invoice PRO-TEST-0001", "Please find attached the proforma invoice PRO-TEST-0001."),
)
def test_line_description_is_editable(self):
sales_order = make_sales_order(qty=10, do_not_submit=True)
sales_order.items[0].description = "Ordered description"
sales_order.submit()
so_detail = sales_order.items[0].name
edited = make_proforma_invoice(
sales_order.name, json.dumps([{"so_detail": so_detail, "qty": 4, "description": "Edited"}])
)
unedited = self.create_proforma(sales_order, [(so_detail, 4)])
self.assertEqual(get_sales_order_items(sales_order.name)[0]["description"], "Ordered description")
self.assertEqual(frappe.get_doc("Proforma Invoice", edited).items[0].description, "Edited")
self.assertEqual(unedited.items[0].description, "Ordered description")
def test_update_items_cannot_delete_a_proformed_row(self):
sales_order = make_sales_order(
item_list=[
{"item_code": "_Test Item", "qty": 5, "rate": 100},
{"item_code": "_Test Item 2", "qty": 2, "rate": 50},
]
)
proformed, other = sales_order.items
proforma = self.create_proforma(sales_order, [(proformed.name, 2)])
keep_other = json.dumps(
[{"item_code": other.item_code, "qty": other.qty, "rate": other.rate, "docname": other.name}]
)
self.assertRaises(
frappe.ValidationError, update_child_qty_rate, "Sales Order", keep_other, sales_order.name
)
proforma.cancel()
update_child_qty_rate("Sales Order", keep_other, sales_order.name)
sales_order.reload()
self.assertEqual([item.name for item in sales_order.items], [other.name])
def test_line_from_another_sales_order_is_rejected(self):
sales_order = make_sales_order(qty=10)
other_item = make_sales_order(qty=10).items[0]
self.assertRaises(
frappe.ValidationError,
self.make_draft_proforma,
sales_order,
so_detail=other_item.name,
item_code=other_item.item_code,
qty=4,
)
def test_quantity_basis_bills_at_sales_order_rate(self):
sales_order = make_sales_order(qty=10)
so_item = sales_order.items[0]
proforma = self.make_draft_proforma(
sales_order, so_detail=so_item.name, item_code=so_item.item_code, qty=4, rate=1, amount=1
)
item = proforma.items[0]
self.assertEqual(item.item_code, so_item.item_code)
self.assertEqual(flt(item.rate), flt(so_item.rate))
self.assertEqual(flt(item.amount), 4 * flt(so_item.rate))
def test_amended_proforma_is_rejected(self):
proforma = frappe.get_doc({"doctype": "Proforma Invoice", "amended_from": "PRO-TEST-0001"})
self.assertRaises(frappe.ValidationError, proforma.validate_amended_doc)
def test_requires_submitted_sales_order(self):
"""The server rejects a proforma against a draft Sales Order (the button is JS-gated only)."""
sales_order = make_sales_order(qty=10, do_not_submit=True)

View File

@@ -7,6 +7,7 @@
"field_order": [
"item_code",
"item_name",
"description",
"column_break_qty",
"qty",
"uom",
@@ -32,6 +33,11 @@
"label": "Item Name",
"read_only": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"fieldname": "column_break_qty",
"fieldtype": "Column Break"
@@ -79,7 +85,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-16 00:00:00.000000",
"modified": "2026-09-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Proforma Invoice Item",

View File

@@ -14,6 +14,7 @@ class ProformaInvoiceItem(Document):
from frappe.types import DF
amount: DF.Currency
description: DF.TextEditor | None
item_code: DF.Link
item_name: DF.Data | None
parent: DF.Data

View File

@@ -9,14 +9,14 @@
"docstatus": 0,
"doctype": "Print Format",
"font_size": 0,
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t\t.proforma-print .footer-note { margin-top: 30px; font-size: 11px; color: #6b7280; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Against Sales Order\") }}</td><td class=\"text-right\">{{ doc.name }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td><strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{% if doc.customer_address %}{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td><strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n\n\t<div class=\"footer-note\">\n\t\t{{ _(\"This is a proforma invoice and is not a demand for payment or a tax invoice.\") }}\n\t</div>\n</div>\n",
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t<strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}\n\t\t\t\t{% if doc.customer_address %}<br>{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td>\n\t\t\t\t\t<strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}\n\t\t\t\t\t{% if row.description and frappe.utils.strip_html(row.description).strip() != row.item_name %}<div class=\"muted\">{{ row.description }}</div>{% endif %}\n\t\t\t\t</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n</div>\n",
"idx": 0,
"line_breaks": 0,
"margin_bottom": 15.0,
"margin_left": 15.0,
"margin_right": 15.0,
"margin_top": 15.0,
"modified": "2026-07-16 00:00:00.000000",
"modified": "2026-09-24 12:30:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Proforma Invoice",

View File

@@ -737,30 +737,6 @@ 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,
)
qty_consumed_per_unit = frappe.get_value(
stock_qty = frappe.get_value(
"BOM Explosion Item" if data.include_exploded_items else "BOM Item",
{"name": rm_item.bom_detail_no},
"qty_consumed_per_unit",
"stock_qty",
)
qty = flt(
qty_consumed_per_unit * data.process_loss_qty,
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

View File

@@ -334,36 +334,6 @@ 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

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

View File

@@ -1,35 +0,0 @@
{
"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

@@ -1,43 +0,0 @@
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

@@ -1,82 +0,0 @@
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

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

View File

@@ -1,35 +0,0 @@
{
"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

@@ -1,90 +0,0 @@
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

@@ -1,95 +0,0 @@
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

@@ -1,27 +0,0 @@
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

@@ -1,35 +0,0 @@
{
"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

@@ -1,118 +0,0 @@
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

@@ -1,47 +0,0 @@
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

@@ -1,48 +0,0 @@
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,69 +65,9 @@
"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-09-24 12:00:00.000000",
"modified": "2026-08-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting",