mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-24 22:07:05 +00:00
Compare commits
10 Commits
develop
...
proforma-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab34dc9025 | ||
|
|
e19c3c6984 | ||
|
|
defff46d8f | ||
|
|
7dd3598156 | ||
|
|
6bf5fa51d5 | ||
|
|
d29ccfb569 | ||
|
|
c1808c9124 | ||
|
|
fa9d7a75e1 | ||
|
|
02854a48ee | ||
|
|
b03e48f453 |
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
frappe.query_reports["Subcontracted Items To Be Delivered"] = {
|
||||
filters: erpnext.get_subcontracting_inward_report_filters(),
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
]
|
||||
@@ -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]
|
||||
@@ -1,3 +0,0 @@
|
||||
frappe.query_reports["Subcontracted Raw Materials To Be Received"] = {
|
||||
filters: erpnext.get_subcontracting_inward_report_filters(),
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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 };
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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"])
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user