Merge pull request #54100 from frappe/version-16-hotfix

This commit is contained in:
diptanilsaha
2026-04-07 23:05:35 +05:30
committed by GitHub
136 changed files with 4555 additions and 1973 deletions

View File

@@ -52,60 +52,55 @@ frappe.treeview_settings["Account"] = {
], ],
root_label: "Accounts", root_label: "Accounts",
get_tree_nodes: "erpnext.accounts.utils.get_children", get_tree_nodes: "erpnext.accounts.utils.get_children",
on_get_node: function (nodes, deep = false) { on_node_render: function (node, deep) {
if (frappe.boot.user.can_read.indexOf("GL Entry") == -1) return; const render_balances = () => {
for (let account of cur_tree.account_balance_data) {
const node = cur_tree.nodes && cur_tree.nodes[account.value];
if (!node || node.is_root) continue;
let accounts = []; // show Dr if positive since balance is calculated as debit - credit else show Cr
if (deep) { const balance = account.balance_in_account_currency || account.balance;
// in case of `get_all_nodes` const dr_or_cr = balance > 0 ? __("Dr") : __("Cr");
accounts = nodes.reduce((acc, node) => [...acc, ...node.data], []); const format = (value, currency) => format_currency(Math.abs(value), currency);
} else {
accounts = nodes;
}
frappe.db.get_single_value("Accounts Settings", "show_balance_in_coa").then((value) => { if (account.balance !== undefined) {
if (value) { node.parent && node.parent.find(".balance-area").remove();
const get_balances = frappe.call({ $(
method: "erpnext.accounts.utils.get_account_balances", '<span class="balance-area pull-right">' +
args: { (account.account_currency != account.company_currency
accounts: accounts, ? format(account.balance_in_account_currency, account.account_currency) +
company: cur_tree.args.company, " / "
include_default_fb_balances: true, : "") +
}, format(account.balance, account.company_currency) +
}); " " +
dr_or_cr +
get_balances.then((r) => { "</span>"
if (!r.message || r.message.length == 0) return; ).insertBefore(node.$ul);
}
for (let account of r.message) {
const node = cur_tree.nodes && cur_tree.nodes[account.value];
if (!node || node.is_root) continue;
// show Dr if positive since balance is calculated as debit - credit else show Cr
const balance = account.balance_in_account_currency || account.balance;
const dr_or_cr = balance > 0 ? __("Dr") : __("Cr");
const format = (value, currency) => format_currency(Math.abs(value), currency);
if (account.balance !== undefined) {
node.parent && node.parent.find(".balance-area").remove();
$(
'<span class="balance-area pull-right">' +
(account.balance_in_account_currency
? format(
account.balance_in_account_currency,
account.account_currency
) + " / "
: "") +
format(account.balance, account.company_currency) +
" " +
dr_or_cr +
"</span>"
).insertBefore(node.$ul);
}
}
});
} }
}); };
if (frappe.boot.user.can_read.indexOf("GL Entry") == -1) return;
if (!cur_tree.account_balance_data) {
frappe.db.get_single_value("Accounts Settings", "show_balance_in_coa").then((value) => {
if (value) {
frappe.call({
method: "erpnext.accounts.utils.get_account_balances_coa",
args: {
company: cur_tree.args.company,
include_default_fb_balances: true,
},
callback: function (r) {
if (!r.message || r.message.length === 0) return;
cur_tree.account_balance_data = r.message || [];
render_balances();
},
});
}
});
} else {
render_balances();
}
}, },
add_tree_node: "erpnext.accounts.utils.add_ac", add_tree_node: "erpnext.accounts.utils.add_ac",
menu_items: [ menu_items: [

View File

@@ -26,8 +26,13 @@
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
"modified": "2025-10-15 03:19:47.171349", {
"link_doctype": "Account",
"link_fieldname": "account_category"
}
],
"modified": "2026-02-23 01:19:49.589393",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Account Category", "name": "Account Category",

View File

@@ -65,6 +65,7 @@
"payment_options_section", "payment_options_section",
"enable_loyalty_point_program", "enable_loyalty_point_program",
"column_break_ctam", "column_break_ctam",
"fetch_payment_schedule_in_payment_request",
"invoicing_settings_tab", "invoicing_settings_tab",
"accounts_transactions_settings_section", "accounts_transactions_settings_section",
"over_billing_allowance", "over_billing_allowance",
@@ -688,6 +689,19 @@
"fieldname": "enable_accounting_dimensions", "fieldname": "enable_accounting_dimensions",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable Accounting Dimensions" "label": "Enable Accounting Dimensions"
},
{
"default": "1",
"description": "Enable Subscription tracking in invoice",
"fieldname": "enable_subscription",
"fieldtype": "Check",
"label": "Enable Subscription"
},
{
"default": "1",
"fieldname": "fetch_payment_schedule_in_payment_request",
"fieldtype": "Check",
"label": "Fetch Payment Schedule In Payment Request"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -697,7 +711,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-02-27 01:04:09.415288", "modified": "2026-03-30 07:32:58.182018",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Accounts Settings", "name": "Accounts Settings",

View File

@@ -73,6 +73,7 @@ class AccountsSettings(Document):
enable_loyalty_point_program: DF.Check enable_loyalty_point_program: DF.Check
enable_party_matching: DF.Check enable_party_matching: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"] exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
fetch_payment_schedule_in_payment_request: DF.Check
fetch_valuation_rate_for_internal_transaction: DF.Check fetch_valuation_rate_for_internal_transaction: DF.Check
general_ledger_remarks_length: DF.Int general_ledger_remarks_length: DF.Int
ignore_account_closing_balance: DF.Check ignore_account_closing_balance: DF.Check

View File

@@ -15,7 +15,7 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestAdvancePaymentLedgerEntry(AccountsTestMixin, ERPNextTestSuite): class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
""" """
Integration tests for AdvancePaymentLedgerEntry. Integration tests for AdvancePaymentLedgerEntry.
Use this class for testing interactions between multiple components. Use this class for testing interactions between multiple components.

View File

@@ -15,7 +15,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestBankReconciliationTool(AccountsTestMixin, ERPNextTestSuite): class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -382,7 +382,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
frappe.get_doc( frappe.get_doc(
{ {
"doctype": "Customer", "doctype": "Customer",
"customer_group": "All Customer Groups", "customer_group": "Individual",
"customer_type": "Company", "customer_type": "Company",
"customer_name": "Poore Simon's", "customer_name": "Poore Simon's",
} }
@@ -413,7 +413,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
frappe.get_doc( frappe.get_doc(
{ {
"doctype": "Customer", "doctype": "Customer",
"customer_group": "All Customer Groups", "customer_group": "Individual",
"customer_type": "Company", "customer_type": "Company",
"customer_name": "Fayva", "customer_name": "Fayva",
} }

View File

@@ -2,10 +2,11 @@
# See license.txt # See license.txt
import frappe import frappe
from frappe.tests import UnitTestCase
from erpnext.tests.utils import ERPNextTestSuite
class TestBankTransactionFees(UnitTestCase): class TestBankTransactionFees(ERPNextTestSuite):
def test_included_fee_throws(self): def test_included_fee_throws(self):
"""A fee that's part of a withdrawal cannot be bigger than the """A fee that's part of a withdrawal cannot be bigger than the
withdrawal itself.""" withdrawal itself."""

View File

@@ -13,7 +13,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestExchangeRateRevaluation(AccountsTestMixin, ERPNextTestSuite): class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_usd_receivable_account() self.create_usd_receivable_account()

View File

@@ -3,6 +3,8 @@
frappe.ui.form.on("Financial Report Template", { frappe.ui.form.on("Financial Report Template", {
refresh(frm) { refresh(frm) {
if (frm.is_new() || frm.doc.rows.length === 0) return;
// add custom button to view missed accounts // add custom button to view missed accounts
frm.add_custom_button(__("View Account Coverage"), function () { frm.add_custom_button(__("View Account Coverage"), function () {
let selected_rows = frm.get_field("rows").grid.get_selected_children(); let selected_rows = frm.get_field("rows").grid.get_selected_children();
@@ -20,7 +22,7 @@ frappe.ui.form.on("Financial Report Template", {
}); });
}, },
validate(frm) { after_save(frm) {
if (!frm.doc.rows || frm.doc.rows.length === 0) { if (!frm.doc.rows || frm.doc.rows.length === 0) {
frappe.msgprint(__("At least one row is required for a financial report template")); frappe.msgprint(__("At least one row is required for a financial report template"));
} }
@@ -34,14 +36,6 @@ frappe.ui.form.on("Financial Report Row", {
update_formula_label(frm, row.data_source); update_formula_label(frm, row.data_source);
update_formula_description(frm, row.data_source); update_formula_description(frm, row.data_source);
if (row.data_source !== "Account Data") {
frappe.model.set_value(cdt, cdn, "balance_type", "");
}
if (["Blank Line", "Column Break", "Section Break"].includes(row.data_source)) {
frappe.model.set_value(cdt, cdn, "calculation_formula", "");
}
set_up_filters_editor(frm, cdt, cdn); set_up_filters_editor(frm, cdt, cdn);
}, },
@@ -322,6 +316,8 @@ function update_formula_description(frm, data_source) {
const list_style = `style="margin-bottom: var(--margin-sm); color: var(--text-muted); font-size: 0.9em;"`; const list_style = `style="margin-bottom: var(--margin-sm); color: var(--text-muted); font-size: 0.9em;"`;
const note_style = `style="margin-bottom: 0; color: var(--text-muted); font-size: 0.9em;"`; const note_style = `style="margin-bottom: 0; color: var(--text-muted); font-size: 0.9em;"`;
const tip_style = `style="margin-bottom: 0; color: var(--text-color); font-size: 0.85em;"`; const tip_style = `style="margin-bottom: 0; color: var(--text-color); font-size: 0.85em;"`;
const code_style = `style="background: var(--bg-light-gray); padding: var(--padding-xs); border-radius: var(--border-radius); font-size: 0.85em; width: max-content; margin-bottom: var(--margin-sm);"`;
const pre_style = `style="margin: 0; border-radius: var(--border-radius)"`;
let description_html = ""; let description_html = "";
@@ -382,8 +378,13 @@ function update_formula_description(frm, data_source) {
<li><code>my_app.financial_reports.get_kpi_data</code></li> <li><code>my_app.financial_reports.get_kpi_data</code></li>
</ul> </ul>
<h6 ${subtitle_style}>Method Signature:</h6>
<div ${code_style}>
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br>&nbsp; # filters: dict — report filters (company, period, etc.) <br>&nbsp; # periods: list[dict] — period definitions <br>&nbsp; # row: dict — the current report row <br><br>&nbsp; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
</div>
<h6 ${subtitle_style}>Return Format:</h6> <h6 ${subtitle_style}>Return Format:</h6>
<p ${text_style}>Numbers for each period: <code>[1000.0, 1200.0, 1150.0]</code></p> <p ${text_style}>A list of numbers, one for each period: <code>[1000.0, 1200.0, 1150.0]</code></p>
</div>`; </div>`;
} else if (data_source === "Blank Line") { } else if (data_source === "Blank Line") {
description_html = ` description_html = `

View File

@@ -1,6 +1,5 @@
{ {
"actions": [], "actions": [],
"allow_rename": 1,
"autoname": "field:template_name", "autoname": "field:template_name",
"creation": "2025-08-02 04:44:15.184541", "creation": "2025-08-02 04:44:15.184541",
"doctype": "DocType", "doctype": "DocType",
@@ -31,7 +30,8 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Report Type", "label": "Report Type",
"options": "\nProfit and Loss Statement\nBalance Sheet\nCash Flow\nCustom Financial Statement" "options": "\nProfit and Loss Statement\nBalance Sheet\nCash Flow\nCustom Financial Statement",
"reqd": 1
}, },
{ {
"depends_on": "eval:frappe.boot.developer_mode", "depends_on": "eval:frappe.boot.developer_mode",
@@ -66,7 +66,7 @@
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-11-14 00:11:03.508139", "modified": "2026-02-23 01:04:05.797161",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Financial Report Template", "name": "Financial Report Template",

View File

@@ -32,6 +32,19 @@ class FinancialReportTemplate(Document):
template_name: DF.Data template_name: DF.Data
# end: auto-generated types # end: auto-generated types
def before_validate(self):
self.clear_hidden_fields()
def clear_hidden_fields(self):
style_data_sources = {"Blank Line", "Column Break", "Section Break"}
for row in self.rows:
if row.data_source != "Account Data":
row.balance_type = None
if row.data_source in style_data_sources:
row.calculation_formula = None
def validate(self): def validate(self):
validator = TemplateValidator(self) validator = TemplateValidator(self)
result = validator.validate() result = validator.validate()

View File

@@ -70,8 +70,8 @@ class ValidationResult:
self.warnings.append(issue) self.warnings.append(issue)
def notify_user(self) -> None: def notify_user(self) -> None:
warnings = "<br><br>".join(str(w) for w in self.warnings) warnings = "<br><br>".join(str(w) for w in self.warnings if w)
errors = "<br><br>".join(str(e) for e in self.issues) errors = "<br><br>".join(str(e) for e in self.issues if e)
if warnings: if warnings:
frappe.msgprint(warnings, title=_("Warnings"), indicator="orange") frappe.msgprint(warnings, title=_("Warnings"), indicator="orange")
@@ -99,9 +99,8 @@ class TemplateValidator:
result.merge(validator.validate(self.template)) result.merge(validator.validate(self.template))
# Run row-level validations # Run row-level validations
account_fields = {field.fieldname for field in frappe.get_meta("Account").fields}
for row in self.template.rows: for row in self.template.rows:
result.merge(self.formula_validator.validate(row, account_fields)) result.merge(self.formula_validator.validate(row))
return result return result
@@ -383,7 +382,8 @@ class AccountFilterValidator(Validator):
"""Validates account filter expressions used in Account Data rows""" """Validates account filter expressions used in Account Data rows"""
def __init__(self, account_fields: set | None = None): def __init__(self, account_fields: set | None = None):
self.account_fields = account_fields or set(frappe.get_meta("Account")._valid_columns) self.account_meta = frappe.get_meta("Account")
self.account_fields = account_fields or set(self.account_meta._valid_columns)
def validate(self, row) -> ValidationResult: def validate(self, row) -> ValidationResult:
result = ValidationResult() result = ValidationResult()
@@ -403,7 +403,11 @@ class AccountFilterValidator(Validator):
try: try:
filter_config = json.loads(row.calculation_formula) filter_config = json.loads(row.calculation_formula)
error = self._validate_filter_structure(filter_config, self.account_fields) error = self._validate_filter_structure(
filter_config,
self.account_fields,
row.advanced_filtering,
)
if error: if error:
result.add_error( result.add_error(
@@ -425,7 +429,12 @@ class AccountFilterValidator(Validator):
return result return result
def _validate_filter_structure(self, filter_config, account_fields: set) -> str | None: def _validate_filter_structure(
self,
filter_config,
account_fields: set,
advanced_filtering: bool = False,
) -> str | None:
# simple condition: [field, operator, value] # simple condition: [field, operator, value]
if isinstance(filter_config, list): if isinstance(filter_config, list):
if len(filter_config) != 3: if len(filter_config) != 3:
@@ -436,8 +445,10 @@ class AccountFilterValidator(Validator):
if not isinstance(field, str) or not isinstance(operator, str): if not isinstance(field, str) or not isinstance(operator, str):
return "Field and operator must be strings" return "Field and operator must be strings"
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
if field not in account_fields: if field not in account_fields:
return f"Field '{field}' is not a valid account field" return f"Field '{display}' is not a valid Account field"
if operator.casefold() not in OPERATOR_MAP: if operator.casefold() not in OPERATOR_MAP:
return f"Invalid operator '{operator}'" return f"Invalid operator '{operator}'"
@@ -460,7 +471,7 @@ class AccountFilterValidator(Validator):
# recursive # recursive
for condition in conditions: for condition in conditions:
error = self._validate_filter_structure(condition, account_fields) error = self._validate_filter_structure(condition, account_fields, advanced_filtering)
if error: if error:
return error return error
else: else:
@@ -476,7 +487,7 @@ class FormulaValidator(Validator):
self.calculation_validator = CalculationFormulaValidator(reference_codes) self.calculation_validator = CalculationFormulaValidator(reference_codes)
self.account_filter_validator = AccountFilterValidator() self.account_filter_validator = AccountFilterValidator()
def validate(self, row, account_fields: set) -> ValidationResult: def validate(self, row) -> ValidationResult:
result = ValidationResult() result = ValidationResult()
if not row.calculation_formula: if not row.calculation_formula:
@@ -486,9 +497,6 @@ class FormulaValidator(Validator):
return self.calculation_validator.validate(row) return self.calculation_validator.validate(row)
elif row.data_source == "Account Data": elif row.data_source == "Account Data":
# Update account fields if provided
if account_fields:
self.account_filter_validator.account_fields = account_fields
return self.account_filter_validator.validate(row) return self.account_filter_validator.validate(row)
elif row.data_source == "Custom API": elif row.data_source == "Custom API":

View File

@@ -1295,6 +1295,7 @@ class TestFilterExpressionParser(FinancialReportTemplateTestCase):
self.data_source = "Account Data" self.data_source = "Account Data"
self.idx = 1 self.idx = 1
self.reverse_sign = 0 self.reverse_sign = 0
self.advanced_filtering = True
return MockReportRow(formula, reference_code) return MockReportRow(formula, reference_code)

View File

@@ -353,8 +353,11 @@ class JournalEntry(AccountsController):
frappe.throw(_("Account {0} should be of type Expense").format(d.account)) frappe.throw(_("Account {0} should be of type Expense").format(d.account))
def validate_stock_accounts(self): def validate_stock_accounts(self):
if self.voucher_type == "Periodic Accounting Entry": if (
# Skip validation for periodic accounting entry not erpnext.is_perpetual_inventory_enabled(self.company)
or self.voucher_type == "Periodic Accounting Entry"
):
# Skip validation for periodic accounting entry and Perpetual Inventory Disabled Company.
return return
stock_accounts = get_stock_accounts(self.company, accounts=self.accounts) stock_accounts = get_stock_accounts(self.company, accounts=self.accounts)

View File

@@ -10,7 +10,7 @@ from erpnext.accounts.utils import run_ledger_health_checks
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestLedgerHealth(AccountsTestMixin, ERPNextTestSuite): class TestLedgerHealth(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -70,9 +70,7 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
}); });
}); });
if (frm.doc.create_missing_party) { frm.trigger("update_party_labels");
frm.set_df_property("party", "fieldtype", "Data", frm.doc.name, "invoices");
}
}, },
setup_company_filters: function (frm) { setup_company_filters: function (frm) {
@@ -127,7 +125,9 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
frappe.model.set_value(row.doctype, row.name, "party", ""); frappe.model.set_value(row.doctype, row.name, "party", "");
frappe.model.set_value(row.doctype, row.name, "party_name", ""); frappe.model.set_value(row.doctype, row.name, "party_name", "");
}); });
frm.clear_table("invoices");
frm.refresh_fields(); frm.refresh_fields();
frm.trigger("update_party_labels");
}, },
make_dashboard: function (frm) { make_dashboard: function (frm) {
@@ -175,6 +175,32 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
} }
frm.refresh_field("invoices"); frm.refresh_field("invoices");
}, },
update_party_labels: function (frm) {
let is_sales = frm.doc.invoice_type == "Sales";
frm.fields_dict["invoices"].grid.update_docfield_property(
"party",
"label",
is_sales ? "Customer ID" : "Supplier ID"
);
frm.fields_dict["invoices"].grid.update_docfield_property(
"party_name",
"label",
is_sales ? "Customer Name" : "Supplier Name"
);
frm.set_df_property(
"create_missing_party",
"description",
is_sales
? __("If party does not exist, create it using the Customer Name field.")
: __("If party does not exist, create it using the Supplier Name field.")
);
frm.refresh_field("invoices");
frm.refresh_field("create_missing_party");
},
}); });
frappe.ui.form.on("Opening Invoice Creation Tool Item", { frappe.ui.form.on("Opening Invoice Creation Tool Item", {

View File

@@ -7,10 +7,11 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"section_break_ynel",
"company", "company",
"create_missing_party",
"column_break_3", "column_break_3",
"invoice_type", "invoice_type",
"create_missing_party",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break", "dimension_col_break",
@@ -25,11 +26,11 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Company", "label": "Company",
"options": "Company", "options": "Company",
"remember_last_selected_value": 1,
"reqd": 1 "reqd": 1
}, },
{ {
"default": "0", "default": "0",
"description": "If party does not exist, create it using the Party Name field.",
"fieldname": "create_missing_party", "fieldname": "create_missing_party",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Create Missing Party" "label": "Create Missing Party"
@@ -79,12 +80,17 @@
{ {
"fieldname": "dimension_col_break", "fieldname": "dimension_col_break",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "section_break_ynel",
"fieldtype": "Section Break",
"hide_border": 1
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-03-23 00:32:15.600086", "modified": "2026-03-31 01:47:20.360352",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Opening Invoice Creation Tool", "name": "Opening Invoice Creation Tool",

View File

@@ -180,7 +180,7 @@ def make_customer(customer=None):
{ {
"doctype": "Customer", "doctype": "Customer",
"customer_name": customer_name, "customer_name": customer_name,
"customer_group": "All Customer Groups", "customer_group": "Individual",
"customer_type": "Company", "customer_type": "Company",
"territory": "All Territories", "territory": "All Territories",
} }

View File

@@ -824,7 +824,7 @@ frappe.ui.form.on("Payment Entry", {
paid_amount: function (frm) { paid_amount: function (frm) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (!frm.doc.received_amount) { if (frm.doc.paid_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.paid_amount); frm.set_value("received_amount", frm.doc.paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) { } else if (company_currency == frm.doc.paid_to_account_currency) {
@@ -845,7 +845,7 @@ frappe.ui.form.on("Payment Entry", {
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate) flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
); );
if (!frm.doc.paid_amount) { if (frm.doc.received_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("paid_amount", frm.doc.received_amount); frm.set_value("paid_amount", frm.doc.received_amount);
if (frm.doc.target_exchange_rate) { if (frm.doc.target_exchange_rate) {

View File

@@ -14,7 +14,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestProcessStatementOfAccounts(AccountsTestMixin, ERPNextTestSuite): class TestProcessStatementOfAccounts(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0) frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
letterhead = frappe.get_doc("Letter Head", "Company Letterhead - Grey") letterhead = frappe.get_doc("Letter Head", "Company Letterhead - Grey")

View File

@@ -21,10 +21,12 @@ frappe.ui.form.on("Promotional Scheme", {
selling: function (frm) { selling: function (frm) {
frm.trigger("set_options_for_applicable_for"); frm.trigger("set_options_for_applicable_for");
frm.toggle_enable("buying", !frm.doc.selling);
}, },
buying: function (frm) { buying: function (frm) {
frm.trigger("set_options_for_applicable_for"); frm.trigger("set_options_for_applicable_for");
frm.toggle_enable("selling", !frm.doc.buying);
}, },
set_options_for_applicable_for: function (frm) { set_options_for_applicable_for: function (frm) {

View File

@@ -983,6 +983,10 @@ class PurchaseInvoice(BuyingController):
if provisional_accounting_for_non_stock_items: if provisional_accounting_for_non_stock_items:
self.get_provisional_accounts() self.get_provisional_accounts()
adjust_incoming_rate = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
for item in self.get("items"): for item in self.get("items"):
if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate): if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate):
if item.item_code: if item.item_code:
@@ -1161,7 +1165,11 @@ class PurchaseInvoice(BuyingController):
) )
# check if the exchange rate has changed # check if the exchange rate has changed
if item.get("purchase_receipt") and self.auto_accounting_for_stock: if (
not adjust_incoming_rate
and item.get("purchase_receipt")
and self.auto_accounting_for_stock
):
if ( if (
exchange_rate_map[item.purchase_receipt] exchange_rate_map[item.purchase_receipt]
and self.conversion_rate != exchange_rate_map[item.purchase_receipt] and self.conversion_rate != exchange_rate_map[item.purchase_receipt]
@@ -1198,6 +1206,7 @@ class PurchaseInvoice(BuyingController):
item=item, item=item,
) )
) )
if ( if (
self.auto_accounting_for_stock self.auto_accounting_for_stock
and self.is_opening == "No" and self.is_opening == "No"

View File

@@ -350,6 +350,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
make_purchase_invoice as create_purchase_invoice, make_purchase_invoice as create_purchase_invoice,
) )
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
pr = make_purchase_receipt( pr = make_purchase_receipt(
company="_Test Company with perpetual inventory", company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1", warehouse="Stores - TCP1",
@@ -368,14 +374,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# fetching the latest GL Entry with exchange gain and loss account account # fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value( amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "credit" "GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
) )
discrepancy_caused_by_exchange_rate_diff = abs( discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[0].base_net_amount - pr.items[0].base_net_amount pi.items[0].base_net_amount - pr.items[0].base_net_amount
) )
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
frappe.db.set_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self): def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as create_purchase_invoice, make_purchase_invoice as create_purchase_invoice,

View File

@@ -190,6 +190,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Received Qty", "label": "Received Qty",
"no_copy": 1, "no_copy": 1,
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -206,7 +207,8 @@
{ {
"fieldname": "rejected_qty", "fieldname": "rejected_qty",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Rejected Qty" "label": "Rejected Qty",
"print_hide": 1
}, },
{ {
"depends_on": "eval:doc.uom != doc.stock_uom", "depends_on": "eval:doc.uom != doc.stock_uom",
@@ -226,6 +228,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "UOM", "label": "UOM",
"options": "UOM", "options": "UOM",
"print_hide": 1,
"reqd": 1 "reqd": 1
}, },
{ {
@@ -261,14 +264,16 @@
"depends_on": "price_list_rate", "depends_on": "price_list_rate",
"fieldname": "discount_percentage", "fieldname": "discount_percentage",
"fieldtype": "Percent", "fieldtype": "Percent",
"label": "Discount on Price List Rate (%)" "label": "Discount on Price List Rate (%)",
"print_hide": 1
}, },
{ {
"depends_on": "price_list_rate", "depends_on": "price_list_rate",
"fieldname": "discount_amount", "fieldname": "discount_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Discount Amount", "label": "Discount Amount",
"options": "currency" "options": "currency",
"print_hide": 1
}, },
{ {
"fieldname": "col_break3", "fieldname": "col_break3",
@@ -401,12 +406,14 @@
{ {
"fieldname": "weight_per_unit", "fieldname": "weight_per_unit",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Weight Per Unit" "label": "Weight Per Unit",
"print_hide": 1
}, },
{ {
"fieldname": "total_weight", "fieldname": "total_weight",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Total Weight", "label": "Total Weight",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -417,7 +424,8 @@
"fieldname": "weight_uom", "fieldname": "weight_uom",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Weight UOM", "label": "Weight UOM",
"options": "UOM" "options": "UOM",
"print_hide": 1
}, },
{ {
"depends_on": "eval:parent.update_stock", "depends_on": "eval:parent.update_stock",
@@ -429,7 +437,8 @@
"fieldname": "warehouse", "fieldname": "warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Accepted Warehouse", "label": "Accepted Warehouse",
"options": "Warehouse" "options": "Warehouse",
"print_hide": 1
}, },
{ {
"fieldname": "rejected_warehouse", "fieldname": "rejected_warehouse",
@@ -674,7 +683,8 @@
"fieldname": "asset_location", "fieldname": "asset_location",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Asset Location", "label": "Asset Location",
"options": "Location" "options": "Location",
"print_hide": 1
}, },
{ {
"fieldname": "po_detail", "fieldname": "po_detail",
@@ -796,6 +806,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Asset Category", "label": "Asset Category",
"options": "Asset Category", "options": "Asset Category",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -828,6 +839,7 @@
"label": "Rate of Stock UOM", "label": "Rate of Stock UOM",
"no_copy": 1, "no_copy": 1,
"options": "currency", "options": "currency",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -866,6 +878,7 @@
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Rate With Margin", "label": "Rate With Margin",
"options": "currency", "options": "currency",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -892,7 +905,8 @@
"default": "1", "default": "1",
"fieldname": "apply_tds", "fieldname": "apply_tds",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Consider for Tax Withholding" "label": "Consider for Tax Withholding",
"print_hide": 1
}, },
{ {
"depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1",
@@ -918,7 +932,8 @@
"fieldname": "wip_composite_asset", "fieldname": "wip_composite_asset",
"fieldtype": "Link", "fieldtype": "Link",
"label": "WIP Composite Asset", "label": "WIP Composite Asset",
"options": "Asset" "options": "Asset",
"print_hide": 1
}, },
{ {
"depends_on": "eval:doc.use_serial_batch_fields === 0 && doc.docstatus === 0", "depends_on": "eval:doc.use_serial_batch_fields === 0 && doc.docstatus === 0",
@@ -930,7 +945,8 @@
"default": "0", "default": "0",
"fieldname": "use_serial_batch_fields", "fieldname": "use_serial_batch_fields",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Use Serial No / Batch Fields" "label": "Use Serial No / Batch Fields",
"print_hide": 1
}, },
{ {
"depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
@@ -977,7 +993,8 @@
"fieldname": "distributed_discount_amount", "fieldname": "distributed_discount_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Distributed Discount Amount", "label": "Distributed Discount Amount",
"options": "currency" "options": "currency",
"print_hide": 1
}, },
{ {
"fieldname": "tax_withholding_category", "fieldname": "tax_withholding_category",
@@ -991,7 +1008,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-02-15 21:07:49.455930", "modified": "2026-03-25 18:03:33.522195",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice Item", "name": "Purchase Invoice Item",

View File

@@ -9,29 +9,25 @@ from frappe.utils import add_days, nowdate, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite): class TestRepostAccountingLedger(ERPNextTestSuite):
def setUp(self): def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0) frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
update_repost_settings() update_repost_settings()
def test_01_basic_functions(self): def test_01_basic_functions(self):
si = create_sales_invoice( si = create_sales_invoice(
item=self.item, item="_Test Item",
company=self.company, company="_Test Company",
customer=self.customer, customer="_Test Customer",
debit_to=self.debit_to, debit_to="Debtors - _TC",
parent_cost_center=self.cost_center, parent_cost_center="Main - _TC",
cost_center=self.cost_center, cost_center="Main - _TC",
rate=100, rate=100,
) )
@@ -48,7 +44,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
# Test Validation Error # Test Validation Error
ral = frappe.new_doc("Repost Accounting Ledger") ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company ral.company = "_Test Company"
ral.delete_cancelled_entries = True ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append( ral.append(
@@ -65,7 +61,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
ral.save() ral.save()
# manually set an incorrect debit amount in DB # manually set an incorrect debit amount in DB
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to}) gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": "Debtors - _TC"})
frappe.db.set_value("GL Entry", gle[0], "debit", 90) frappe.db.set_value("GL Entry", gle[0], "debit", 90)
gl = qb.DocType("GL Entry") gl = qb.DocType("GL Entry")
@@ -94,23 +90,23 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
def test_02_deferred_accounting_valiations(self): def test_02_deferred_accounting_valiations(self):
si = create_sales_invoice( si = create_sales_invoice(
item=self.item, item="_Test Item",
company=self.company, company="_Test Company",
customer=self.customer, customer="_Test Customer",
debit_to=self.debit_to, debit_to="Debtors - _TC",
parent_cost_center=self.cost_center, parent_cost_center="Main - _TC",
cost_center=self.cost_center, cost_center="Main - _TC",
rate=100, rate=100,
do_not_submit=True, do_not_submit=True,
) )
si.items[0].enable_deferred_revenue = True si.items[0].enable_deferred_revenue = True
si.items[0].deferred_revenue_account = self.deferred_revenue si.items[0].deferred_revenue_account = "Deferred Revenue - _TC"
si.items[0].service_start_date = nowdate() si.items[0].service_start_date = nowdate()
si.items[0].service_end_date = add_days(nowdate(), 90) si.items[0].service_end_date = add_days(nowdate(), 90)
si.save().submit() si.save().submit()
ral = frappe.new_doc("Repost Accounting Ledger") ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save) self.assertRaises(frappe.ValidationError, ral.save)
@@ -118,35 +114,35 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
def test_04_pcv_validation(self): def test_04_pcv_validation(self):
# Clear old GL entries so PCV can be submitted. # Clear old GL entries so PCV can be submitted.
gl = frappe.qb.DocType("GL Entry") gl = frappe.qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == self.company).run() qb.from_(gl).delete().where(gl.company == "_Test Company").run()
si = create_sales_invoice( si = create_sales_invoice(
item=self.item, item="_Test Item",
company=self.company, company="_Test Company",
customer=self.customer, customer="_Test Customer",
debit_to=self.debit_to, debit_to="Debtors - _TC",
parent_cost_center=self.cost_center, parent_cost_center="Main - _TC",
cost_center=self.cost_center, cost_center="Main - _TC",
rate=100, rate=100,
) )
fy = get_fiscal_year(today(), company=self.company) fy = get_fiscal_year(today(), company="_Test Company")
pcv = frappe.get_doc( pcv = frappe.get_doc(
{ {
"doctype": "Period Closing Voucher", "doctype": "Period Closing Voucher",
"transaction_date": today(), "transaction_date": today(),
"period_start_date": fy[1], "period_start_date": fy[1],
"period_end_date": today(), "period_end_date": today(),
"company": self.company, "company": "_Test Company",
"fiscal_year": fy[0], "fiscal_year": fy[0],
"cost_center": self.cost_center, "cost_center": "Main - _TC",
"closing_account_head": self.retained_earnings, "closing_account_head": "Retained Earnings - _TC",
"remarks": "test", "remarks": "test",
} }
) )
pcv.save().submit() pcv.save().submit()
ral = frappe.new_doc("Repost Accounting Ledger") ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save) self.assertRaises(frappe.ValidationError, ral.save)
@@ -156,12 +152,12 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
def test_03_deletion_flag_and_preview_function(self): def test_03_deletion_flag_and_preview_function(self):
si = create_sales_invoice( si = create_sales_invoice(
item=self.item, item="_Test Item",
company=self.company, company="_Test Company",
customer=self.customer, customer="_Test Customer",
debit_to=self.debit_to, debit_to="Debtors - _TC",
parent_cost_center=self.cost_center, parent_cost_center="Main - _TC",
cost_center=self.cost_center, cost_center="Main - _TC",
rate=100, rate=100,
) )
@@ -170,7 +166,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
# with deletion flag set # with deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger") ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company ral.company = "_Test Company"
ral.delete_cancelled_entries = True ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name}) ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
@@ -181,12 +177,12 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
def test_05_without_deletion_flag(self): def test_05_without_deletion_flag(self):
si = create_sales_invoice( si = create_sales_invoice(
item=self.item, item="_Test Item",
company=self.company, company="_Test Company",
customer=self.customer, customer="_Test Customer",
debit_to=self.debit_to, debit_to="Debtors - _TC",
parent_cost_center=self.cost_center, parent_cost_center="Main - _TC",
cost_center=self.cost_center, cost_center="Main - _TC",
rate=100, rate=100,
) )
@@ -195,7 +191,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
# without deletion flag set # without deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger") ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company ral.company = "_Test Company"
ral.delete_cancelled_entries = False ral.delete_cancelled_entries = False
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name}) ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
@@ -210,16 +206,16 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
provisional_account = create_account( provisional_account = create_account(
account_name="Provision Account", account_name="Provision Account",
parent_account="Current Liabilities - _TC", parent_account="Current Liabilities - _TC",
company=self.company, company="_Test Company",
) )
another_provisional_account = create_account( another_provisional_account = create_account(
account_name="Another Provision Account", account_name="Another Provision Account",
parent_account="Current Liabilities - _TC", parent_account="Current Liabilities - _TC",
company=self.company, company="_Test Company",
) )
company = frappe.get_doc("Company", self.company) company = frappe.get_doc("Company", "_Test Company")
company.enable_provisional_accounting_for_non_stock_items = 1 company.enable_provisional_accounting_for_non_stock_items = 1
company.default_provisional_account = provisional_account company.default_provisional_account = provisional_account
company.save() company.save()
@@ -229,7 +225,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
item = make_item(properties={"is_stock_item": 0}) item = make_item(properties={"is_stock_item": 0})
pr = make_purchase_receipt(company=self.company, item_code=item.name, rate=1000.0, qty=1.0) pr = make_purchase_receipt(company="_Test Company", item_code=item.name, rate=1000.0, qty=1.0)
pr_gl_entries = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True) pr_gl_entries = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
expected_pr_gles = [ expected_pr_gles = [
{"account": provisional_account, "debit": 0.0, "credit": 1000.0, "cost_center": test_cc}, {"account": provisional_account, "debit": 0.0, "credit": 1000.0, "cost_center": test_cc},
@@ -246,7 +242,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
) )
repost_doc = frappe.new_doc("Repost Accounting Ledger") repost_doc = frappe.new_doc("Repost Accounting Ledger")
repost_doc.company = self.company repost_doc.company = "_Test Company"
repost_doc.delete_cancelled_entries = True repost_doc.delete_cancelled_entries = True
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name}) repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
repost_doc.save().submit() repost_doc.save().submit()

View File

@@ -207,6 +207,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Stock UOM", "label": "Stock UOM",
"options": "UOM", "options": "UOM",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -310,7 +311,8 @@
"fieldname": "discount_amount", "fieldname": "discount_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Discount Amount", "label": "Discount Amount",
"options": "currency" "options": "currency",
"print_hide": 1
}, },
{ {
"depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount", "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount",
@@ -853,6 +855,7 @@
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Rate of Stock UOM", "label": "Rate of Stock UOM",
"no_copy": 1, "no_copy": 1,
"print_hide": 1,
"options": "currency", "options": "currency",
"read_only": 1 "read_only": 1
}, },
@@ -869,6 +872,7 @@
"fieldname": "grant_commission", "fieldname": "grant_commission",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Grant Commission", "label": "Grant Commission",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -926,7 +930,8 @@
"default": "0", "default": "0",
"fieldname": "use_serial_batch_fields", "fieldname": "use_serial_batch_fields",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Use Serial No / Batch Fields" "label": "Use Serial No / Batch Fields",
"print_hide": 1
}, },
{ {
"depends_on": "eval:doc.use_serial_batch_fields === 1 && parent.update_stock === 1", "depends_on": "eval:doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
@@ -941,7 +946,8 @@
"fieldname": "distributed_discount_amount", "fieldname": "distributed_discount_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Distributed Discount Amount", "label": "Distributed Discount Amount",
"options": "currency" "options": "currency",
"print_hide": 1
}, },
{ {
"fieldname": "available_quantity_section", "fieldname": "available_quantity_section",
@@ -1010,7 +1016,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-02-23 14:37:14.853941", "modified": "2026-02-24 14:37:16.853941",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice Item", "name": "Sales Invoice Item",

View File

@@ -128,6 +128,7 @@ class TaxWithholdingDetails:
self.party_type = party_type self.party_type = party_type
self.party = party self.party = party
self.company = company self.company = company
self.tax_id = get_tax_id_for_party(self.party_type, self.party)
def get(self) -> list: def get(self) -> list:
""" """
@@ -161,6 +162,7 @@ class TaxWithholdingDetails:
disable_cumulative_threshold=doc.disable_cumulative_threshold, disable_cumulative_threshold=doc.disable_cumulative_threshold,
disable_transaction_threshold=doc.disable_transaction_threshold, disable_transaction_threshold=doc.disable_transaction_threshold,
taxable_amount=0, taxable_amount=0,
tax_id=self.tax_id,
) )
# ldc (only if valid based on posting date) # ldc (only if valid based on posting date)
@@ -181,17 +183,13 @@ class TaxWithholdingDetails:
if self.party_type != "Supplier": if self.party_type != "Supplier":
return ldc_details return ldc_details
# NOTE: This can be a configurable option
# To check if filter by tax_id is needed
tax_id = get_tax_id_for_party(self.party_type, self.party)
# ldc details # ldc details
ldc_records = self.get_valid_ldc_records(tax_id) ldc_records = self.get_valid_ldc_records(self.tax_id)
if not ldc_records: if not ldc_records:
return ldc_details return ldc_details
ldc_names = [ldc.name for ldc in ldc_records] ldc_names = [ldc.name for ldc in ldc_records]
ldc_utilization_map = self.get_ldc_utilization_by_category(ldc_names, tax_id) ldc_utilization_map = self.get_ldc_utilization_by_category(ldc_names, self.tax_id)
# map # map
for ldc in ldc_records: for ldc in ldc_records:
@@ -254,4 +252,5 @@ class TaxWithholdingDetails:
@allow_regional @allow_regional
def get_tax_id_for_party(party_type, party): def get_tax_id_for_party(party_type, party):
return None # cannot use tax_id from doc because payment and journal entry do not have tax_id field.\
return frappe.db.get_value(party_type, party, "tax_id")

View File

@@ -2,6 +2,7 @@
# See license.txt # See license.txt
import datetime import datetime
from unittest.mock import patch
import frappe import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
@@ -3541,6 +3542,47 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
entry.withholding_amount = 5001 # Should be 5000 (10% of 50000) entry.withholding_amount = 5001 # Should be 5000 (10% of 50000)
self.assertRaisesRegex(frappe.ValidationError, "Withholding Amount.*does not match", pi.save) self.assertRaisesRegex(frappe.ValidationError, "Withholding Amount.*does not match", pi.save)
def test_tax_id_is_set_in_all_generated_entries_from_party_doctype(self):
self.setup_party_with_category("Supplier", "Test TDS Supplier3", "New TDS Category")
frappe.db.set_value("Supplier", "Test TDS Supplier3", "tax_id", "ABCTY1234D")
pi = create_purchase_invoice(supplier="Test TDS Supplier3", rate=40000)
pi.submit()
entries = frappe.get_all(
"Tax Withholding Entry",
filters={"parenttype": "Purchase Invoice", "parent": pi.name},
fields=["name", "tax_id"],
)
self.assertTrue(entries)
self.assertTrue(all(entry.tax_id == "ABCTY1234D" for entry in entries))
def test_threshold_considers_two_parties_with_same_tax_id_with_overrided_hook(self):
self.setup_party_with_category("Supplier", "Test TDS Supplier1", "Cumulative Threshold TDS")
self.setup_party_with_category("Supplier", "Test TDS Supplier2", "Cumulative Threshold TDS")
with patch(
"erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category.get_tax_id_for_party",
return_value="AAAPL1234C",
):
pi1 = create_purchase_invoice(supplier="Test TDS Supplier1", rate=20000)
pi1.submit()
pi2 = create_purchase_invoice(supplier="Test TDS Supplier2", rate=20000)
pi2.submit()
entries = frappe.get_all(
"Tax Withholding Entry",
filters={"parenttype": "Purchase Invoice", "parent": pi2.name},
fields=["status", "withholding_amount"],
)
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0].status, "Settled")
self.assertEqual(entries[0].withholding_amount, 2000.0)
def create_purchase_invoice(**args): def create_purchase_invoice(**args):
# return sales invoice doc object # return sales invoice doc object

View File

@@ -344,7 +344,6 @@ class TaxWithholdingEntry(Document):
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
TaxWithholdingDetails, TaxWithholdingDetails,
get_tax_id_for_party,
) )
@@ -646,8 +645,11 @@ class TaxWithholdingController:
# NOTE: This can be a configurable option # NOTE: This can be a configurable option
# To check if filter by tax_id is needed # To check if filter by tax_id is needed
tax_id = get_tax_id_for_party(self.party_type, self.party) query = (
query = query.where(entry.tax_id == tax_id) if tax_id else query.where(entry.party == self.party) query.where(entry.tax_id == category.tax_id)
if category.tax_id
else query.where(entry.party == self.party)
)
return query return query
@@ -686,6 +688,7 @@ class TaxWithholdingController:
"company": self.doc.company, "company": self.doc.company,
"party_type": self.party_type, "party_type": self.party_type,
"party": self.party, "party": self.party,
"tax_id": category.tax_id,
"tax_withholding_category": category.name, "tax_withholding_category": category.name,
"tax_withholding_group": category.tax_withholding_group, "tax_withholding_group": category.tax_withholding_group,
"tax_rate": category.tax_rate, "tax_rate": category.tax_rate,
@@ -1052,6 +1055,7 @@ class TaxWithholdingController:
"party_type": self.party_type, "party_type": self.party_type,
"party": self.party, "party": self.party,
"company": self.doc.company, "company": self.doc.company,
"tax_id": category.tax_id,
} }
) )
return entry return entry

View File

@@ -14,7 +14,7 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestUnreconcilePayment(AccountsTestMixin, ERPNextTestSuite): class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -7,7 +7,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsPayable(AccountsTestMixin, ERPNextTestSuite): class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -10,7 +10,7 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsReceivable(AccountsTestMixin, ERPNextTestSuite): class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -8,7 +8,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsReceivable(AccountsTestMixin, ERPNextTestSuite): class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.maxDiff = None self.maxDiff = None
self.create_company() self.create_company()

View File

@@ -10,7 +10,7 @@ from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestCustomerLedgerSummary(AccountsTestMixin, ERPNextTestSuite): class TestCustomerLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -74,6 +74,7 @@ frappe.query_reports["General Ledger"] = {
label: __("Party"), label: __("Party"),
fieldtype: "MultiSelectList", fieldtype: "MultiSelectList",
options: "party_type", options: "party_type",
depends_on: "party_type",
get_data: function (txt) { get_data: function (txt) {
if (!frappe.query_report.filters) return; if (!frappe.query_report.filters) return;

View File

@@ -7,7 +7,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestItemWisePurchaseRegister(AccountsTestMixin, ERPNextTestSuite): class TestItemWisePurchaseRegister(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_supplier() self.create_supplier()

View File

@@ -7,7 +7,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseSalesRegister(AccountsTestMixin, ERPNextTestSuite): class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -12,7 +12,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestProfitAndLossStatement(AccountsTestMixin, ERPNextTestSuite): class TestProfitAndLossStatement(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -7,7 +7,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseSalesRegister(AccountsTestMixin, ERPNextTestSuite): class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_customer() self.create_customer()

View File

@@ -7,7 +7,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestSupplierLedgerSummary(AccountsTestMixin, ERPNextTestSuite): class TestSupplierLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.create_supplier() self.create_supplier()

View File

@@ -16,7 +16,7 @@ from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestTaxWithholdingDetails(AccountsTestMixin, ERPNextTestSuite): class TestTaxWithholdingDetails(ERPNextTestSuite, AccountsTestMixin):
def setUp(self): def setUp(self):
self.create_company() self.create_company()
self.clear_old_entries() self.clear_old_entries()

View File

@@ -229,23 +229,3 @@ class AccountsTestMixin:
] ]
for doctype in doctype_list: for doctype in doctype_list:
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run() qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
def create_price_list(self):
pl_name = "Mixin Price List"
if not frappe.db.exists("Price List", pl_name):
self.price_list = (
frappe.get_doc(
{
"doctype": "Price List",
"currency": "INR",
"enabled": True,
"selling": True,
"buying": True,
"price_list_name": pl_name,
}
)
.insert()
.name
)
else:
self.price_list = frappe.get_doc("Price List", pl_name).name

View File

@@ -1404,6 +1404,78 @@ def get_account_balances(accounts, company, finance_book=None, include_default_f
return accounts return accounts
@frappe.whitelist()
def get_account_balances_coa(company: str, include_default_fb_balances: bool = False):
company_currency = frappe.get_cached_value("Company", company, "default_currency")
Account = DocType("Account")
account_list = (
frappe.qb.from_(Account)
.select(Account.name, Account.parent_account, Account.account_currency)
.where(Account.company == company)
.orderby(Account.lft)
.run(as_dict=True)
)
account_balances_cc = {account.get("name"): 0 for account in account_list}
account_balances_ac = {account.get("name"): 0 for account in account_list}
GLEntry = DocType("GL Entry")
precision = get_currency_precision()
get_ledger_balances_query = (
frappe.qb.from_(GLEntry)
.select(
GLEntry.account,
(Sum(Round(GLEntry.debit, precision)) - Sum(Round(GLEntry.credit, precision))).as_("balance"),
(
Sum(Round(GLEntry.debit_in_account_currency, precision))
- Sum(Round(GLEntry.credit_in_account_currency, precision))
).as_("balance_in_account_currency"),
)
.groupby(GLEntry.account)
)
condition_list = [GLEntry.company == company, GLEntry.is_cancelled == 0]
default_finance_book = None
if include_default_fb_balances:
default_finance_book = frappe.get_cached_value("Company", company, "default_finance_book")
if default_finance_book:
condition_list.append(
(GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull())
)
for condition in condition_list:
get_ledger_balances_query = get_ledger_balances_query.where(condition)
ledger_balances = get_ledger_balances_query.run(as_dict=True)
for ledger_entry in ledger_balances:
account_balances_cc[ledger_entry.get("account")] = ledger_entry.get("balance")
account_balances_ac[ledger_entry.get("account")] = ledger_entry.get("balance_in_account_currency")
for account in reversed(account_list):
parent = account.get("parent_account")
if parent:
account_balances_cc[parent] += account_balances_cc.get(account.get("name"))
accounts_data = [
{
"value": account.get("name"),
"company_currency": company_currency,
"balance": account_balances_cc.get(account.get("name")),
"account_currency": account.get("account_currency"),
"balance_in_account_currency": account_balances_ac.get(account.get("name")),
}
for account in account_list
]
return accounts_data
def create_payment_gateway_account(gateway, payment_channel="Email", company=None): def create_payment_gateway_account(gateway, payment_channel="Email", company=None):
from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account

View File

@@ -130,7 +130,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Asset", "label": "Asset",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]", "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]",
"options": "Asset", "options": "Asset",
"reqd": 1 "reqd": 1
}, },

View File

@@ -291,6 +291,30 @@ class TestPurchaseOrder(ERPNextTestSuite):
# ordered qty should decrease (back to initial) on row deletion # ordered qty should decrease (back to initial) on row deletion
self.assertEqual(get_ordered_qty(), existing_ordered_qty) self.assertEqual(get_ordered_qty(), existing_ordered_qty)
def test_discount_amount_partial_purchase_receipt(self):
po = create_purchase_order(qty=4, rate=100, do_not_save=1)
po.apply_discount_on = "Grand Total"
po.discount_amount = 120
po.save()
po.submit()
self.assertEqual(po.grand_total, 280)
pr1 = make_purchase_receipt(po.name)
pr1.items[0].qty = 3
pr1.save()
pr1.submit()
self.assertEqual(pr1.discount_amount, 120)
self.assertEqual(pr1.grand_total, 180)
pr2 = make_purchase_receipt(po.name)
pr2.save()
pr2.submit()
self.assertEqual(pr2.discount_amount, 0)
self.assertEqual(pr2.grand_total, 100)
def test_update_child_perm(self): def test_update_child_perm(self):
po = create_purchase_order(item_code="_Test Item", qty=4) po = create_purchase_order(item_code="_Test Item", qty=4)

View File

@@ -280,14 +280,16 @@
"depends_on": "price_list_rate", "depends_on": "price_list_rate",
"fieldname": "discount_percentage", "fieldname": "discount_percentage",
"fieldtype": "Percent", "fieldtype": "Percent",
"label": "Discount on Price List Rate (%)" "label": "Discount on Price List Rate (%)",
"print_hide": 1
}, },
{ {
"depends_on": "price_list_rate", "depends_on": "price_list_rate",
"fieldname": "discount_amount", "fieldname": "discount_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Discount Amount", "label": "Discount Amount",
"options": "currency" "options": "currency",
"print_hide": 1
}, },
{ {
"fieldname": "col_break3", "fieldname": "col_break3",
@@ -428,6 +430,7 @@
"fieldname": "weight_per_unit", "fieldname": "weight_per_unit",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Weight Per Unit", "label": "Weight Per Unit",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -763,6 +766,7 @@
"label": "Rate of Stock UOM", "label": "Rate of Stock UOM",
"no_copy": 1, "no_copy": 1,
"options": "currency", "options": "currency",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -779,6 +783,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Available Qty at Company", "label": "Available Qty at Company",
"no_copy": 1, "no_copy": 1,
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -878,7 +883,8 @@
"fieldname": "fg_item_qty", "fieldname": "fg_item_qty",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Finished Good Qty", "label": "Finished Good Qty",
"mandatory_depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow" "mandatory_depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow",
"print_hide": 1
}, },
{ {
"depends_on": "eval:parent.is_internal_supplier", "depends_on": "eval:parent.is_internal_supplier",
@@ -923,7 +929,8 @@
"fieldname": "distributed_discount_amount", "fieldname": "distributed_discount_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Distributed Discount Amount", "label": "Distributed Discount Amount",
"options": "currency" "options": "currency",
"print_hide": 1
}, },
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
@@ -934,6 +941,7 @@
"label": "Subcontracted Quantity", "label": "Subcontracted Quantity",
"no_copy": 1, "no_copy": 1,
"non_negative": 1, "non_negative": 1,
"print_hide": 1,
"read_only": 1 "read_only": 1
} }
], ],
@@ -942,7 +950,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-10-30 16:51:56.761673", "modified": "2025-11-30 16:51:57.761673",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Purchase Order Item", "name": "Purchase Order Item",

View File

@@ -167,6 +167,15 @@ def create_supplier(**args):
if not args.without_supplier_group: if not args.without_supplier_group:
doc.supplier_group = args.supplier_group or "Services" doc.supplier_group = args.supplier_group or "Services"
if args.get("party_account"):
doc.append(
"accounts",
{
"company": frappe.db.get_value("Account", args.get("party_account"), "company"),
"account": args.get("party_account"),
},
)
doc.insert() doc.insert()
return doc return doc

View File

@@ -457,7 +457,7 @@ class BuyingController(SubcontractingController):
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
) )
net_rate = item.base_net_amount net_rate = item.qty * item.base_net_rate
if item.sales_incoming_rate: # for internal transfer if item.sales_incoming_rate: # for internal transfer
net_rate = item.qty * item.sales_incoming_rate net_rate = item.qty * item.sales_incoming_rate

View File

@@ -1435,7 +1435,7 @@ class StockController(AccountsController):
elif self.doctype == "Stock Entry" and row.t_warehouse: elif self.doctype == "Stock Entry" and row.t_warehouse:
qi_required = True # inward stock needs inspection qi_required = True # inward stock needs inspection
if row.get("is_scrap_item"): if row.get("type") or row.get("is_legacy_scrap_item"):
continue continue
if qi_required: # validate row only if inspection is required on item level if qi_required: # validate row only if inspection is required on item level

View File

@@ -160,7 +160,7 @@ class SubcontractingController(StockController):
).format(item.idx, get_link_to_form("Item", item.item_code)) ).format(item.idx, get_link_to_form("Item", item.item_code))
) )
if not item.get("is_scrap_item"): if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not is_sub_contracted_item: if not is_sub_contracted_item:
frappe.throw( frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name) _("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
@@ -206,7 +206,7 @@ class SubcontractingController(StockController):
).format(item.idx, item.item_name) ).format(item.idx, item.item_name)
) )
if self.doctype != "Subcontracting Inward Order": if self.doctype not in ["Subcontracting Inward Order", "Subcontracting Receipt"]:
item.amount = item.qty * item.rate item.amount = item.qty * item.rate
if item.bom: if item.bom:
@@ -238,7 +238,7 @@ class SubcontractingController(StockController):
and self._doc_before_save and self._doc_before_save
): ):
for row in self._doc_before_save.get("items"): for row in self._doc_before_save.get("items"):
item_dict[row.name] = (row.item_code, row.qty + (row.get("rejected_qty") or 0)) item_dict[row.name] = (row.item_code, row.received_qty)
return item_dict return item_dict
@@ -264,7 +264,7 @@ class SubcontractingController(StockController):
self.__reference_name.append(row.name) self.__reference_name.append(row.name)
if (row.name not in item_dict) or ( if (row.name not in item_dict) or (
row.item_code, row.item_code,
row.qty + (row.get("rejected_qty") or 0), row.received_qty,
) != item_dict[row.name]: ) != item_dict[row.name]:
self.__changed_name.append(row.name) self.__changed_name.append(row.name)
@@ -962,7 +962,7 @@ class SubcontractingController(StockController):
): ):
qty = ( qty = (
flt(bom_item.qty_consumed_per_unit) flt(bom_item.qty_consumed_per_unit)
* flt(row.qty + (row.get("rejected_qty") or 0)) * flt(row.get("received_qty") or (row.qty + (row.get("rejected_qty") or 0)))
* row.conversion_factor * row.conversion_factor
) )
bom_item.main_item_code = row.item_code bom_item.main_item_code = row.item_code
@@ -1285,22 +1285,28 @@ class SubcontractingController(StockController):
if self.total_additional_costs: if self.total_additional_costs:
if self.distribute_additional_costs_based_on == "Amount": if self.distribute_additional_costs_based_on == "Amount":
total_amt = sum( total_amt = sum(
flt(item.amount) for item in self.get("items") if not item.get("is_scrap_item") flt(item.amount)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
) )
for item in self.items: for item in self.items:
if not item.get("is_scrap_item"): if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = ( item.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt (item.amount * self.total_additional_costs) / total_amt
) / item.qty ) / item.qty
else: else:
total_qty = sum(flt(item.qty) for item in self.get("items") if not item.get("is_scrap_item")) total_qty = sum(
flt(item.qty)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
)
additional_cost_per_qty = self.total_additional_costs / total_qty additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items: for item in self.items:
if not item.get("is_scrap_item"): if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = additional_cost_per_qty item.additional_cost_per_qty = additional_cost_per_qty
else: else:
for item in self.items: for item in self.items:
if not item.get("is_scrap_item"): if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = 0 item.additional_cost_per_qty = 0
@frappe.whitelist() @frappe.whitelist()

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
import frappe import frappe
from frappe import _, bold from frappe import _, bold
from frappe.query_builder import Case from frappe.query_builder import Case
@@ -18,7 +20,7 @@ class SubcontractingInwardController:
def on_submit_subcontracting_inward(self): def on_submit_subcontracting_inward(self):
self.update_inward_order_item() self.update_inward_order_item()
self.update_inward_order_received_items() self.update_inward_order_received_items()
self.update_inward_order_scrap_items() self.update_inward_order_secondary_items()
self.create_stock_reservation_entries_for_inward() self.create_stock_reservation_entries_for_inward()
self.update_inward_order_status() self.update_inward_order_status()
@@ -28,7 +30,7 @@ class SubcontractingInwardController:
self.validate_delivery() self.validate_delivery()
self.validate_receive_from_customer_cancel() self.validate_receive_from_customer_cancel()
self.update_inward_order_received_items() self.update_inward_order_received_items()
self.update_inward_order_scrap_items() self.update_inward_order_secondary_items()
self.remove_reference_for_additional_items() self.remove_reference_for_additional_items()
self.update_inward_order_status() self.update_inward_order_status()
@@ -239,7 +241,8 @@ class SubcontractingInwardController:
item item
for item in self.get("items") for item in self.get("items")
if not item.is_finished_item if not item.is_finished_item
and not item.is_scrap_item and not item.type
and not item.is_legacy_scrap_item
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item") and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
] ]
@@ -368,7 +371,9 @@ class SubcontractingInwardController:
if self.subcontracting_inward_order: if self.subcontracting_inward_order:
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]: if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
for item in self.items: for item in self.items:
if (item.is_finished_item or item.is_scrap_item) and item.valuation_rate == 0: if (
item.is_finished_item or item.type or item.is_legacy_scrap_item
) and item.valuation_rate == 0:
item.allow_zero_valuation_rate = 1 item.allow_zero_valuation_rate = 1
def validate_warehouse_(self): def validate_warehouse_(self):
@@ -467,7 +472,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save() self.validate_delivery_on_save()
else: else:
for item in self.items: for item in self.items:
if not item.is_scrap_item: if not item.type and not item.is_legacy_scrap_item:
delivered_qty, returned_qty = frappe.get_value( delivered_qty, returned_qty = frappe.get_value(
"Subcontracting Inward Order Item", "Subcontracting Inward Order Item",
item.scio_detail, item.scio_detail,
@@ -519,7 +524,7 @@ class SubcontractingInwardController:
if max_allowed_qty: if max_allowed_qty:
max_allowed_qty = max_allowed_qty[0] max_allowed_qty = max_allowed_qty[0]
else: else:
table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item") table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
query = ( query = (
frappe.qb.from_(table) frappe.qb.from_(table)
.select((table.produced_qty - table.delivered_qty).as_("max_allowed_qty")) .select((table.produced_qty - table.delivered_qty).as_("max_allowed_qty"))
@@ -538,8 +543,8 @@ class SubcontractingInwardController:
bold( bold(
frappe.get_cached_value( frappe.get_cached_value(
"Subcontracting Inward Order Item" "Subcontracting Inward Order Item"
if not item.is_scrap_item if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Scrap Item", else "Subcontracting Inward Order Secondary Item",
item.scio_detail, item.scio_detail,
"stock_uom", "stock_uom",
) )
@@ -590,9 +595,9 @@ class SubcontractingInwardController:
) )
for item in [item for item in self.items if not item.is_finished_item]: for item in [item for item in self.items if not item.is_finished_item]:
if item.is_scrap_item: if item.type or item.is_legacy_scrap_item:
scio_scrap_item = frappe.get_value( scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Scrap Item", "Subcontracting Inward Order Secondary Item",
{ {
"docstatus": 1, "docstatus": 1,
"item_code": item.item_code, "item_code": item.item_code,
@@ -603,12 +608,13 @@ class SubcontractingInwardController:
as_dict=True, as_dict=True,
) )
if ( if (
scio_scrap_item scio_secondary_item
and scio_scrap_item.delivered_qty > scio_scrap_item.produced_qty - item.transfer_qty and scio_secondary_item.delivered_qty
> scio_secondary_item.produced_qty - item.transfer_qty
): ):
frappe.throw( frappe.throw(
_( _(
"Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Scrap Item {1} produced cannot be less than quantity delivered." "Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Secondary Item {1} produced cannot be less than quantity delivered."
).format(item.idx, get_link_to_form("Item", item.item_code)) ).format(item.idx, get_link_to_form("Item", item.item_code))
) )
else: else:
@@ -648,8 +654,8 @@ class SubcontractingInwardController:
for item in self.items: for item in self.items:
doctype = ( doctype = (
"Subcontracting Inward Order Item" "Subcontracting Inward Order Item"
if not item.is_scrap_item if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Scrap Item" else "Subcontracting Inward Order Secondary Item"
) )
frappe.db.set_value( frappe.db.set_value(
doctype, doctype,
@@ -763,7 +769,11 @@ class SubcontractingInwardController:
customer_warehouse = frappe.get_cached_value( customer_warehouse = frappe.get_cached_value(
"Subcontracting Inward Order", self.subcontracting_inward_order, "customer_warehouse" "Subcontracting Inward Order", self.subcontracting_inward_order, "customer_warehouse"
) )
items = [item for item in self.items if not item.is_finished_item and not item.is_scrap_item] items = [
item
for item in self.items
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item
]
item_code_wh = frappe._dict( item_code_wh = frappe._dict(
{ {
( (
@@ -860,24 +870,24 @@ class SubcontractingInwardController:
doc.insert() doc.insert()
doc.submit() doc.submit()
def update_inward_order_scrap_items(self): def update_inward_order_secondary_items(self):
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture": if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
scrap_items_list = [item for item in self.items if item.is_scrap_item] secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item]
scrap_items = frappe._dict(
{ secondary_items = defaultdict(float)
(item.item_code, item.t_warehouse): item.transfer_qty for item in secondary_items_list:
if self._action == "submit" secondary_items[(item.item_code, item.t_warehouse)] += (
else -item.transfer_qty item.transfer_qty if self._action == "submit" else -item.transfer_qty
for item in scrap_items_list )
} secondary_items = frappe._dict(secondary_items)
)
if scrap_items: if secondary_items:
item_codes, warehouses = zip(*list(scrap_items.keys()), strict=True) item_codes, warehouses = zip(*list(secondary_items.keys()), strict=True)
item_codes = list(item_codes) item_codes = list(item_codes)
warehouses = list(warehouses) warehouses = list(warehouses)
result = frappe.get_all( result = frappe.get_all(
"Subcontracting Inward Order Scrap Item", "Subcontracting Inward Order Secondary Item",
filters={ filters={
"item_code": ["in", item_codes], "item_code": ["in", item_codes],
"warehouse": ["in", warehouses], "warehouse": ["in", warehouses],
@@ -890,7 +900,7 @@ class SubcontractingInwardController:
) )
if result: if result:
scrap_item_dict = frappe._dict( secondary_items_dict = frappe._dict(
{ {
(d.item_code, d.warehouse): frappe._dict( (d.item_code, d.warehouse): frappe._dict(
{"name": d.name, "produced_qty": d.produced_qty} {"name": d.name, "produced_qty": d.produced_qty}
@@ -900,40 +910,45 @@ class SubcontractingInwardController:
) )
deleted_docs = [] deleted_docs = []
case_expr = Case() case_expr = Case()
table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item") table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
for key, value in scrap_item_dict.items(): for key, value in secondary_items_dict.items():
if self._action == "cancel" and value.produced_qty - abs(scrap_items.get(key)) == 0: if (
self._action == "cancel"
and value.produced_qty - abs(secondary_items.get(key)) == 0
):
deleted_docs.append(value.name) deleted_docs.append(value.name)
frappe.delete_doc("Subcontracting Inward Order Scrap Item", value.name) frappe.delete_doc("Subcontracting Inward Order Secondary Item", value.name)
else: else:
case_expr = case_expr.when( case_expr = case_expr.when(
table.name == value.name, value.produced_qty + scrap_items.get(key) table.name == value.name, value.produced_qty + secondary_items.get(key)
) )
if final_list := list( if final_list := list(
set([v.name for v in scrap_item_dict.values()]) - set(deleted_docs) set([v.name for v in secondary_items_dict.values()]) - set(deleted_docs)
): ):
frappe.qb.update(table).set(table.produced_qty, case_expr).where( frappe.qb.update(table).set(table.produced_qty, case_expr).where(
(table.name.isin(final_list)) & (table.docstatus == 1) (table.name.isin(final_list)) & (table.docstatus == 1)
).run() ).run()
fg_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code fg_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code
for scrap_item in [ for secondary_item in [
item item
for item in scrap_items_list for item in secondary_items_list
if (item.item_code, item.t_warehouse) not in [(d.item_code, d.warehouse) for d in result] if (item.item_code, item.t_warehouse) not in [(d.item_code, d.warehouse) for d in result]
]: ]:
doc = frappe.new_doc( doc = frappe.new_doc(
"Subcontracting Inward Order Scrap Item", "Subcontracting Inward Order Secondary Item",
parent=scio, parent=scio,
parenttype="Subcontracting Inward Order", parenttype="Subcontracting Inward Order",
parentfield="scrap_items", parentfield="secondary_items",
idx=frappe.db.count("Subcontracting Inward Order Scrap Item", {"parent": scio}) + 1, idx=frappe.db.count("Subcontracting Inward Order Secondary Item", {"parent": scio})
item_code=scrap_item.item_code, + 1,
item_code=secondary_item.item_code,
fg_item_code=fg_item_code, fg_item_code=fg_item_code,
stock_uom=scrap_item.stock_uom, stock_uom=secondary_item.stock_uom,
warehouse=scrap_item.t_warehouse, warehouse=secondary_item.t_warehouse,
produced_qty=scrap_item.transfer_qty, produced_qty=secondary_item.transfer_qty,
type=secondary_item.type,
delivered_qty=0, delivered_qty=0,
reference_name=frappe.get_value( reference_name=frappe.get_value(
"Work Order", self.work_order, "subcontracting_inward_order_item" "Work Order", self.work_order, "subcontracting_inward_order_item"
@@ -965,7 +980,7 @@ class SubcontractingInwardController:
and ( and (
not frappe.db.exists("Subcontracting Inward Order Received Item", item.scio_detail) not frappe.db.exists("Subcontracting Inward Order Received Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Item", item.scio_detail) and not frappe.db.exists("Subcontracting Inward Order Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Scrap Item", item.scio_detail) and not frappe.db.exists("Subcontracting Inward Order Secondary Item", item.scio_detail)
) )
] ]
for item in items: for item in items:

View File

@@ -164,6 +164,9 @@ class calculate_taxes_and_totals:
return return
if not self.discount_amount_applied: if not self.discount_amount_applied:
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
for item in self.doc.items: for item in self.doc.items:
self.doc.round_floats_in(item) self.doc.round_floats_in(item)
@@ -225,7 +228,13 @@ class calculate_taxes_and_totals:
elif not item.qty and self.doc.get("is_debit_note"): elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount")) item.amount = flt(item.rate, item.precision("amount"))
else: else:
item.amount = flt(item.rate * item.qty, item.precision("amount")) qty = (
(item.qty + item.rejected_qty)
if bill_for_rejected_quantity_in_purchase_invoice
and self.doc.doctype == "Purchase Receipt"
else item.qty
)
item.amount = flt(item.rate * qty, item.precision("amount"))
item.net_amount = item.amount item.net_amount = item.amount
@@ -285,6 +294,13 @@ class calculate_taxes_and_totals:
self.doc._item_wise_tax_details = item_wise_tax_details self.doc._item_wise_tax_details = item_wise_tax_details
self.doc.item_wise_tax_details = [] self.doc.item_wise_tax_details = []
for tax in self.doc.get("taxes"):
if not tax.get("dont_recompute_tax"):
tax._running_txn_tax_total = 0.0
tax._running_base_tax_total = 0.0
tax._running_txn_taxable_total = 0.0
tax._running_base_taxable_total = 0.0
def determine_exclusive_rate(self): def determine_exclusive_rate(self):
if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")): if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")):
return return
@@ -372,9 +388,16 @@ class calculate_taxes_and_totals:
self.doc.total self.doc.total
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0 ) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
for item in self._items: for item in self._items:
self.doc.total += item.amount self.doc.total += item.amount
self.doc.total_qty += item.qty self.doc.total_qty += (
(item.qty + item.rejected_qty)
if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt"
else item.qty
)
self.doc.base_total += item.base_amount self.doc.base_total += item.base_amount
self.doc.net_total += item.net_amount self.doc.net_total += item.net_amount
self.doc.base_net_total += item.base_net_amount self.doc.base_net_total += item.base_net_amount
@@ -521,7 +544,6 @@ class calculate_taxes_and_totals:
actual_breakup = tax._total_tax_breakup actual_breakup = tax._total_tax_breakup
diff = flt(expected_amount - actual_breakup, 5) diff = flt(expected_amount - actual_breakup, 5)
# TODO: fix rounding difference issues
if abs(diff) <= 0.5: if abs(diff) <= 0.5:
detail_row = self.doc._item_wise_tax_details[last_idx] detail_row = self.doc._item_wise_tax_details[last_idx]
detail_row["amount"] = flt(detail_row["amount"] + diff, 5) detail_row["amount"] = flt(detail_row["amount"] + diff, 5)
@@ -597,14 +619,29 @@ class calculate_taxes_and_totals:
def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount): def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount):
# store tax breakup for each item # store tax breakup for each item
multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1 multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1
item_wise_tax_amount = flt(
current_tax_amount * self.doc.conversion_rate * multiplier, tax.precision("tax_amount") # Error diffusion: derive each item's base amount as a delta of the running cumulative total
# so the sum always equals base_tax_amount_after_discount_amount.
tax._running_txn_tax_total += current_tax_amount * multiplier
new_base_tax_total = flt(
flt(tax._running_txn_tax_total, tax.precision("tax_amount")) * self.doc.conversion_rate,
tax.precision("base_tax_amount"),
) )
item_wise_tax_amount = flt(
new_base_tax_total - tax._running_base_tax_total, tax.precision("base_tax_amount")
)
tax._running_base_tax_total = new_base_tax_total
if tax.charge_type != "On Item Quantity": if tax.charge_type != "On Item Quantity":
item_wise_taxable_amount = flt( tax._running_txn_taxable_total += current_net_amount * multiplier
current_net_amount * self.doc.conversion_rate * multiplier, tax.precision("tax_amount") new_base_taxable_total = flt(
flt(tax._running_txn_taxable_total, tax.precision("net_amount")) * self.doc.conversion_rate,
tax.precision("base_net_amount"),
) )
item_wise_taxable_amount = flt(
new_base_taxable_total - tax._running_base_taxable_total, tax.precision("base_net_amount")
)
tax._running_base_taxable_total = new_base_taxable_total
else: else:
item_wise_taxable_amount = 0.0 item_wise_taxable_amount = 0.0
@@ -788,7 +825,8 @@ class calculate_taxes_and_totals:
discount_amount += total_return_discount discount_amount += total_return_discount
# validate that discount amount cannot exceed the total before discount # validate that discount amount cannot exceed the total before discount
if ( # only during save (i.e. when `_action` is set)
if self.doc.get("_action") and (
(grand_total >= 0 and discount_amount > grand_total) (grand_total >= 0 and discount_amount > grand_total)
or (grand_total < 0 and discount_amount < grand_total) # returns or (grand_total < 0 and discount_amount < grand_total) # returns
): ):

View File

@@ -1,10 +1,9 @@
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestTaxesAndTotals(AccountsTestMixin, ERPNextTestSuite): class TestTaxesAndTotals(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_distributed_discount_amount(self): def test_distributed_discount_amount(self):
so = make_sales_order(do_not_save=1) so = make_sales_order(do_not_save=1)

View File

@@ -1,9 +1,9 @@
import json import json
import frappe import frappe
from frappe.utils import flt
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals from erpnext.tests.utils import ERPNextTestSuite, change_settings
from erpnext.tests.utils import ERPNextTestSuite
class TestTaxesAndTotals(ERPNextTestSuite): class TestTaxesAndTotals(ERPNextTestSuite):
@@ -124,3 +124,180 @@ class TestTaxesAndTotals(ERPNextTestSuite):
] ]
self.assertEqual(actual_values, expected_values) self.assertEqual(actual_values, expected_values)
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_item_wise_tax_detail_high_conversion_rate(self):
"""
With a high conversion rate (e.g. USD -> KRW ~1300), independently rounding
each item's base tax amount causes per-item errors that accumulate and exceed
the 0.5-unit safety threshold, raising a validation error.
Error diffusion fixes this: the cumulative base total after the last item
equals base_tax_amount_after_discount_amount exactly, so the sum of all
per-item amounts is always exact regardless of item count or rate magnitude.
Analytically with conversion_rate=1300, rate=7.77 x3 items, VAT 16%:
per-item txn tax = 1.2432
OLD independent: flt(1.2432 * 1300, 2) = 1616.16 -> sum 4848.48
expected base: flt(flt(3.7296, 2) * 1300, 0) = flt(3.73 * 1300, 0) = 4849
diff = 0.52 -> exceeds 0.5 threshold -> would throw with old code
"""
doc = frappe.get_doc(
{
"doctype": "Sales Invoice",
"customer": "_Test Customer",
"company": "_Test Company",
"currency": "USD",
"debit_to": "_Test Receivable USD - _TC",
"conversion_rate": 1300,
"items": [
{
"item_code": "_Test Item",
"qty": 1,
"rate": 7.77,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
{
"item_code": "_Test Item",
"qty": 1,
"rate": 7.77,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
{
"item_code": "_Test Item",
"qty": 1,
"rate": 7.77,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
],
"taxes": [
{
"charge_type": "On Net Total",
"account_head": "_Test Account VAT - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"rate": 16,
},
{
"charge_type": "On Previous Row Amount",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Service Tax",
"rate": 10,
"row_id": 1,
},
],
}
)
doc.save()
details_by_tax = {}
for detail in doc.item_wise_tax_details:
bucket = details_by_tax.setdefault(detail.tax_row, 0.0)
details_by_tax[detail.tax_row] = bucket + detail.amount
for tax in doc.taxes:
self.assertEqual(details_by_tax[tax.name], tax.base_tax_amount_after_discount_amount)
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_rounding_in_item_wise_tax_details(self):
"""
This test verifies the amounts are properly rounded.
"""
doc = frappe.get_doc(
{
"doctype": "Sales Invoice",
"customer": "_Test Customer",
"company": "_Test Company",
"currency": "INR",
"conversion_rate": 1,
"items": [
{
"item_code": "_Test Item",
"qty": 5,
"rate": 20,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
{
"item_code": "_Test Item",
"qty": 3,
"rate": 19,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
{
"item_code": "_Test Item",
"qty": 1,
"rate": 1000,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
],
"taxes": [
{
"charge_type": "On Net Total",
"account_head": "_Test Account VAT - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"rate": 9,
},
],
}
)
doc.save()
# item 1: taxable=100, tax=9.0; item 2: taxable=57, tax=5.13; item 3: taxable=1000, tax=90.0
# error diffusion: 14.13 - 9.0 = 5.130000000000001 without rounding
for detail in doc.item_wise_tax_details:
self.assertEqual(detail.amount, flt(detail.amount, detail.precision("amount")))
def test_item_wise_tax_detail_with_multi_currency_with_single_item(self):
"""
When the tax amount (in transaction currency) has more decimals than
the field precision, rounding must happen *before* multiplying by
conversion_rate — the same order used by _set_in_company_currency.
"""
doc = frappe.get_doc(
{
"doctype": "Sales Invoice",
"customer": "_Test Customer",
"company": "_Test Company",
"currency": "USD",
"debit_to": "_Test Receivable USD - _TC",
"conversion_rate": 129.99,
"items": [
{
"item_code": "_Test Item",
"qty": 1,
"rate": 47.41,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
}
],
"taxes": [
{
"charge_type": "On Net Total",
"account_head": "_Test Account VAT - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"rate": 16,
},
],
}
)
doc.save()
tax = doc.taxes[0]
detail = doc.item_wise_tax_details[0]
self.assertEqual(detail.amount, tax.base_tax_amount_after_discount_amount)

View File

@@ -68,7 +68,7 @@ class TestTaxes(ERPNextTestSuite):
{ {
"doctype": "Customer", "doctype": "Customer",
"customer_name": uuid4(), "customer_name": uuid4(),
"customer_group": "All Customer Groups", "customer_group": "Individual",
} }
).insert() ).insert()
self.supplier = frappe.get_doc( self.supplier = frappe.get_doc(

View File

@@ -2,36 +2,17 @@ import frappe
from frappe import qb from frappe import qb
from frappe.utils import today from frappe.utils import today
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import disable_dimension
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestReactivity(AccountsTestMixin, ERPNextTestSuite): class TestReactivity(ERPNextTestSuite):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.create_usd_receivable_account()
self.create_price_list()
self.clear_old_entries()
def disable_dimensions(self):
res = frappe.db.get_all("Accounting Dimension", filters={"disabled": False})
for x in res:
dim = frappe.get_doc("Accounting Dimension", x.name)
dim.disabled = True
dim.save()
def test_01_basic_item_details(self): def test_01_basic_item_details(self):
self.disable_dimensions()
# set Item Price # set Item Price
frappe.get_doc( frappe.get_doc(
{ {
"doctype": "Item Price", "doctype": "Item Price",
"item_code": self.item, "item_code": "_Test Item",
"price_list": self.price_list, "price_list": "Standard Selling",
"price_list_rate": 90, "price_list_rate": 90,
"selling": True, "selling": True,
"rate": 90, "rate": 90,
@@ -42,17 +23,18 @@ class TestReactivity(AccountsTestMixin, ERPNextTestSuite):
si = frappe.get_doc( si = frappe.get_doc(
{ {
"doctype": "Sales Invoice", "doctype": "Sales Invoice",
"company": self.company, "company": "_Test Company",
"customer": self.customer, "customer": "_Test Customer",
"debit_to": self.debit_to, "debit_to": "Debtors - _TC",
"posting_date": today(), "posting_date": today(),
"cost_center": self.cost_center, "cost_center": "Main - _TC",
"currency": "INR",
"conversion_rate": 1, "conversion_rate": 1,
"selling_price_list": self.price_list, "selling_price_list": "Standard Selling",
} }
) )
itm = si.append("items") itm = si.append("items")
itm.item_code = self.item itm.item_code = "_Test Item"
si.process_item_selection(itm.idx) si.process_item_selection(itm.idx)
self.assertEqual(itm.rate, 90) self.assertEqual(itm.rate, 90)

View File

@@ -501,8 +501,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr1.items[0].qty = 2 scr1.items[0].qty = 2
add_second_row_in_scr(scr1) add_second_row_in_scr(scr1)
scr1.flags.ignore_mandatory = True scr1.flags.ignore_mandatory = True
scr1.save()
scr1.set_missing_values() scr1.set_missing_values()
scr1.save()
scr1.submit() scr1.submit()
for _key, value in get_supplied_items(scr1).items(): for _key, value in get_supplied_items(scr1).items():
@@ -513,8 +513,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr2.items[0].qty = 2 scr2.items[0].qty = 2
add_second_row_in_scr(scr2) add_second_row_in_scr(scr2)
scr2.flags.ignore_mandatory = True scr2.flags.ignore_mandatory = True
scr2.save()
scr2.set_missing_values() scr2.set_missing_values()
scr2.save()
scr2.submit() scr2.submit()
for _key, value in get_supplied_items(scr2).items(): for _key, value in get_supplied_items(scr2).items():
@@ -523,8 +523,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr3 = make_subcontracting_receipt(sco.name) scr3 = make_subcontracting_receipt(sco.name)
scr3.items[0].qty = 2 scr3.items[0].qty = 2
scr3.flags.ignore_mandatory = True scr3.flags.ignore_mandatory = True
scr3.save()
scr3.set_missing_values() scr3.set_missing_values()
scr3.save()
scr3.submit() scr3.submit()
for _key, value in get_supplied_items(scr3).items(): for _key, value in get_supplied_items(scr3).items():
@@ -1164,6 +1164,54 @@ class TestSubcontractingController(ERPNextTestSuite):
self.assertEqual([item.rm_item_code for item in sco.supplied_items], expected) self.assertEqual([item.rm_item_code for item in sco.supplied_items], expected)
def test_co_by_product(self):
frappe.set_value("UOM", "Nos", "must_be_whole_number", 0)
fg_item = make_item("FG Item", properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name
rm_item = make_item("RM Item", properties={"is_stock_item": 1}).name
scrap_item = make_item("Scrap Item", properties={"is_stock_item": 1}).name
make_bom(
item=fg_item, raw_materials=[rm_item], scrap_items=[scrap_item], process_loss_percentage=10
).name
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 11",
"qty": 5,
"rate": 100,
"fg_item": fg_item,
"fg_item_qty": 5,
},
]
sco = get_subcontracting_order(service_items=service_items)
rm_items = get_rm_items(sco.supplied_items)
itemwise_details = make_stock_in_entry(rm_items=rm_items)
make_stock_transfer_entry(
sco_no=sco.name,
rm_items=rm_items,
itemwise_details=copy.deepcopy(itemwise_details),
)
scr1 = make_subcontracting_receipt(sco.name)
scr1.get_secondary_items()
scr1.save()
self.assertEqual(scr1.items[0].received_qty, 5)
self.assertEqual(scr1.items[0].process_loss_qty, 0.5)
self.assertEqual(scr1.items[0].qty, 4.5)
self.assertEqual(scr1.items[0].rate, 200)
self.assertEqual(scr1.items[0].amount, 900)
self.assertEqual(scr1.items[1].item_code, scrap_item)
self.assertEqual(scr1.items[1].received_qty, 5)
self.assertEqual(scr1.items[1].process_loss_qty, 0.5)
self.assertEqual(scr1.items[1].qty, 4.5)
self.assertEqual(flt(scr1.items[1].rate, 3), 11.111)
self.assertEqual(scr1.items[1].amount, 50)
frappe.set_value("UOM", "Nos", "must_be_whole_number", 1)
def add_second_row_in_scr(scr): def add_second_row_in_scr(scr):
item_dict = {} item_dict = {}

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from erpnext.tests.utils import ERPNextTestSuite
class TestCodeList(FrappeTestCase): class TestCodeList(ERPNextTestSuite):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from erpnext.tests.utils import ERPNextTestSuite
class TestCommonCode(FrappeTestCase): class TestCommonCode(ERPNextTestSuite):
pass pass

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,21 @@ frappe.ui.form.on("BOM", {
}; };
}); });
frm.set_query("workstation", "operations", function (doc, cdt, cdn) {
let row = locals[cdt][cdn];
let filters = {
disabled: 0,
};
if (row.workstation_type) {
filters.workstation_type = row.workstation_type;
}
return {
filters: filters,
};
});
frm.set_query("operation", "items", function () { frm.set_query("operation", "items", function () {
if (!frm.doc.operations?.length) { if (!frm.doc.operations?.length) {
frappe.throw(__("Please add Operations first.")); frappe.throw(__("Please add Operations first."));
@@ -123,7 +138,16 @@ frappe.ui.form.on("BOM", {
}, },
toggle_fields_for_semi_finished_goods(frm) { toggle_fields_for_semi_finished_goods(frm) {
let fields = ["finished_good", "finished_good_qty", "bom_no"]; let fields = [
"finished_good",
"finished_good_qty",
"bom_no",
"skip_material_transfer",
"wip_warehouse",
"fg_warehouse",
"is_subcontracted",
"is_final_finished_good",
];
fields.forEach((field) => { fields.forEach((field) => {
frm.fields_dict["operations"].grid.update_docfield_property( frm.fields_dict["operations"].grid.update_docfield_property(
@@ -131,9 +155,21 @@ frappe.ui.form.on("BOM", {
"read_only", "read_only",
!frm.doc.track_semi_finished_goods !frm.doc.track_semi_finished_goods
); );
frm.fields_dict["operations"].grid.update_docfield_property(
field,
"in_list_view",
frm.doc.track_semi_finished_goods
);
frm.fields_dict["operations"].grid.update_docfield_property(
field,
"hidden",
!frm.doc.track_semi_finished_goods
);
}); });
refresh_field("operations"); frm.fields_dict["operations"].grid.reset_grid();
}, },
with_operations: function (frm) { with_operations: function (frm) {
@@ -173,6 +209,8 @@ frappe.ui.form.on("BOM", {
refresh(frm) { refresh(frm) {
frm.toggle_enable("item", frm.doc.__islocal); frm.toggle_enable("item", frm.doc.__islocal);
frm.trigger("toggle_fields_for_semi_finished_goods");
frm.set_indicator_formatter("item_code", function (doc) { frm.set_indicator_formatter("item_code", function (doc) {
if (doc.original_item) { if (doc.original_item) {
return doc.item_code != doc.original_item ? "orange" : ""; return doc.item_code != doc.original_item ? "orange" : "";
@@ -369,6 +407,7 @@ frappe.ui.form.on("BOM", {
reqd: 1, reqd: 1,
default: 1, default: 1,
onchange: () => { onchange: () => {
if (!cur_dialog) return;
const { quantity, items: rm } = frm.doc; const { quantity, items: rm } = frm.doc;
const variant_items_map = rm.reduce((acc, item) => { const variant_items_map = rm.reduce((acc, item) => {
acc[item.item_code] = item.qty; acc[item.item_code] = item.qty;
@@ -620,10 +659,10 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
} }
item_code(doc, cdt, cdn) { item_code(doc, cdt, cdn) {
var scrap_items = false; let secondary_items = false;
var child = locals[cdt][cdn]; var child = locals[cdt][cdn];
if (child.doctype == "BOM Scrap Item") { if (child.doctype == "BOM Secondary Item") {
scrap_items = true; secondary_items = true;
} }
if (child.bom_no) { if (child.bom_no) {
@@ -634,7 +673,7 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
child.do_not_explode = 1; child.do_not_explode = 1;
} }
get_bom_material_detail(doc, cdt, cdn, scrap_items); get_bom_material_detail(doc, cdt, cdn, secondary_items);
} }
buying_price_list(doc) { buying_price_list(doc) {
@@ -683,7 +722,7 @@ cur_frm.cscript.is_default = function (doc) {
if (doc.is_default) cur_frm.set_value("is_active", 1); if (doc.is_default) cur_frm.set_value("is_active", 1);
}; };
var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) { var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
if (!doc.company) { if (!doc.company) {
frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") }); frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") });
} }
@@ -697,7 +736,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
company: doc.company, company: doc.company,
item_code: d.item_code, item_code: d.item_code,
bom_no: d.bom_no != null ? d.bom_no : "", bom_no: d.bom_no != null ? d.bom_no : "",
scrap_items: scrap_items,
qty: d.qty, qty: d.qty,
stock_qty: d.stock_qty, stock_qty: d.stock_qty,
include_item_in_manufacturing: d.include_item_in_manufacturing, include_item_in_manufacturing: d.include_item_in_manufacturing,
@@ -706,15 +744,15 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
conversion_factor: d.conversion_factor, conversion_factor: d.conversion_factor,
sourced_by_supplier: d.sourced_by_supplier, sourced_by_supplier: d.sourced_by_supplier,
do_not_explode: d.do_not_explode, do_not_explode: d.do_not_explode,
fetch_rate: !secondary_items,
}, },
callback: function (r) { callback: function (r) {
$.extend(d, r.message); $.extend(d, r.message);
refresh_field("items"); refresh_field("items");
refresh_field("scrap_items"); refresh_field("secondary_items");
doc = locals[doc.doctype][doc.name]; doc = locals[doc.doctype][doc.name];
erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc); erpnext.bom.calculate_total(doc);
}, },
freeze: true, freeze: true,
@@ -724,20 +762,18 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
cur_frm.cscript.qty = function (doc) { cur_frm.cscript.qty = function (doc) {
erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc); erpnext.bom.calculate_total(doc);
}; };
cur_frm.cscript.rate = function (doc, cdt, cdn) { cur_frm.cscript.rate = function (doc, cdt, cdn) {
var d = locals[cdt][cdn]; var d = locals[cdt][cdn];
const is_scrap_item = cdt == "BOM Scrap Item"; const is_secondary_item = cdt == "BOM Secondary Item";
if (d.bom_no) { if (d.bom_no) {
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item.")); frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
get_bom_material_detail(doc, cdt, cdn, is_scrap_item); get_bom_material_detail(doc, cdt, cdn, is_secondary_item);
} else { } else {
erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc); erpnext.bom.calculate_total(doc);
} }
}; };
@@ -745,7 +781,6 @@ cur_frm.cscript.rate = function (doc, cdt, cdn) {
erpnext.bom.update_cost = function (doc) { erpnext.bom.update_cost = function (doc) {
erpnext.bom.calculate_op_cost(doc); erpnext.bom.calculate_op_cost(doc);
erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc); erpnext.bom.calculate_total(doc);
}; };
@@ -804,34 +839,11 @@ erpnext.bom.calculate_rm_cost = function (doc) {
cur_frm.set_value("base_raw_material_cost", base_total_rm_cost); cur_frm.set_value("base_raw_material_cost", base_total_rm_cost);
}; };
// sm : scrap material
erpnext.bom.calculate_scrap_materials_cost = function (doc) {
var sm = doc.scrap_items || [];
var total_sm_cost = 0;
var base_total_sm_cost = 0;
for (var i = 0; i < sm.length; i++) {
var base_rate = flt(sm[i].rate) * flt(doc.conversion_rate);
var amount = flt(sm[i].rate) * flt(sm[i].stock_qty);
var base_amount = amount * flt(doc.conversion_rate);
frappe.model.set_value("BOM Scrap Item", sm[i].name, "base_rate", base_rate);
frappe.model.set_value("BOM Scrap Item", sm[i].name, "amount", amount);
frappe.model.set_value("BOM Scrap Item", sm[i].name, "base_amount", base_amount);
total_sm_cost += amount;
base_total_sm_cost += base_amount;
}
cur_frm.set_value("scrap_material_cost", total_sm_cost);
cur_frm.set_value("base_scrap_material_cost", base_total_sm_cost);
};
// Calculate Total Cost // Calculate Total Cost
erpnext.bom.calculate_total = function (doc) { erpnext.bom.calculate_total = function (doc) {
var total_cost = flt(doc.operating_cost) + flt(doc.raw_material_cost) - flt(doc.scrap_material_cost); var total_cost = flt(doc.operating_cost) + flt(doc.raw_material_cost) - flt(doc.secondary_items_cost);
var base_total_cost = var base_total_cost =
flt(doc.base_operating_cost) + flt(doc.base_raw_material_cost) - flt(doc.base_scrap_material_cost); flt(doc.base_operating_cost) + flt(doc.base_raw_material_cost) - flt(doc.base_secondary_items_cost);
cur_frm.set_value("total_cost", total_cost); cur_frm.set_value("total_cost", total_cost);
cur_frm.set_value("base_total_cost", base_total_cost); cur_frm.set_value("base_total_cost", base_total_cost);
@@ -891,6 +903,11 @@ frappe.ui.form.on("BOM Operation", "workstation", function (frm, cdt, cdn) {
frappe.ui.form.on("BOM Operation", "workstation_type", function (frm, cdt, cdn) { frappe.ui.form.on("BOM Operation", "workstation_type", function (frm, cdt, cdn) {
var d = locals[cdt][cdn]; var d = locals[cdt][cdn];
if (!d.workstation_type) return; if (!d.workstation_type) return;
if (d.workstation) {
frappe.model.set_value(cdt, cdn, "workstation", "");
}
frappe.call({ frappe.call({
method: "frappe.client.get", method: "frappe.client.get",
args: { args: {
@@ -986,7 +1003,7 @@ frappe.tour["BOM"] = [
}, },
]; ];
frappe.ui.form.on("BOM Scrap Item", { frappe.ui.form.on("BOM Secondary Item", {
item_code(frm, cdt, cdn) { item_code(frm, cdt, cdn) {
const { item_code } = locals[cdt][cdn]; const { item_code } = locals[cdt][cdn];
}, },
@@ -1007,7 +1024,7 @@ function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) {
const row = locals[cdt][cdn]; const row = locals[cdt][cdn];
row.stock_qty = (frm.doc.quantity * data.percent) / 100; row.stock_qty = (frm.doc.quantity * data.percent) / 100;
row.qty = row.stock_qty / (row.conversion_factor || 1); row.qty = row.stock_qty / (row.conversion_factor || 1);
refresh_field("scrap_items"); refresh_field("secondary_items");
}, },
__("Set Process Loss Item Quantity"), __("Set Process Loss Item Quantity"),
__("Set Quantity") __("Set Quantity")

View File

@@ -16,6 +16,14 @@
"allow_alternative_item", "allow_alternative_item",
"set_rate_of_sub_assembly_item_based_on_bom", "set_rate_of_sub_assembly_item_based_on_bom",
"is_phantom_bom", "is_phantom_bom",
"cost_allocation_section",
"cost_allocation_per",
"column_break_srby",
"cost_allocation",
"process_loss_section",
"process_loss_percentage",
"column_break_ssj2",
"process_loss_qty",
"currency_detail", "currency_detail",
"rm_cost_as_per", "rm_cost_as_per",
"buying_price_list", "buying_price_list",
@@ -38,21 +46,16 @@
"operations", "operations",
"materials_section", "materials_section",
"items", "items",
"scrap_section", "secondary_items_tab",
"scrap_items_section", "secondary_items",
"scrap_items",
"process_loss_section",
"process_loss_percentage",
"column_break_ssj2",
"process_loss_qty",
"costing", "costing",
"operating_cost", "operating_cost",
"raw_material_cost", "raw_material_cost",
"scrap_material_cost", "secondary_items_cost",
"cb1", "cb1",
"base_operating_cost", "base_operating_cost",
"base_raw_material_cost", "base_raw_material_cost",
"base_scrap_material_cost", "base_secondary_items_cost",
"column_break_26", "column_break_26",
"total_cost", "total_cost",
"base_total_cost", "base_total_cost",
@@ -298,19 +301,6 @@
"options": "BOM Item", "options": "BOM Item",
"reqd": 1 "reqd": 1
}, },
{
"collapsible": 1,
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "scrap_section",
"fieldtype": "Tab Break",
"label": "Scrap & Process Loss"
},
{
"fieldname": "scrap_items",
"fieldtype": "Table",
"label": "Scrap Items",
"options": "BOM Scrap Item"
},
{ {
"fieldname": "costing", "fieldname": "costing",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
@@ -332,15 +322,6 @@
"options": "currency", "options": "currency",
"read_only": 1 "read_only": 1
}, },
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "scrap_material_cost",
"fieldtype": "Currency",
"label": "Scrap Material Cost",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{ {
"fieldname": "cb1", "fieldname": "cb1",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@@ -362,15 +343,6 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "base_scrap_material_cost",
"fieldtype": "Currency",
"label": "Scrap Material Cost(Company Currency)",
"no_copy": 1,
"options": "Company:company:default_currency",
"read_only": 1
},
{ {
"fieldname": "total_cost", "fieldname": "total_cost",
"fieldtype": "Currency", "fieldtype": "Currency",
@@ -602,12 +574,6 @@
"fieldname": "column_break_ivyw", "fieldname": "column_break_ivyw",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "scrap_items_section",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Scrap Items"
},
{ {
"default": "0", "default": "0",
"fieldname": "fg_based_operating_cost", "fieldname": "fg_based_operating_cost",
@@ -706,6 +672,59 @@
"fieldname": "quality_inspection_tab", "fieldname": "quality_inspection_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Quality Inspection" "label": "Quality Inspection"
},
{
"fieldname": "secondary_items",
"fieldtype": "Table",
"label": "Secondary Items",
"options": "BOM Secondary Item"
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "secondary_items_cost",
"fieldtype": "Currency",
"label": "Secondary Items Cost",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "base_secondary_items_cost",
"fieldtype": "Currency",
"label": "Secondary Items Cost (Company Currency)",
"no_copy": 1,
"options": "Company:company:default_currency",
"read_only": 1
},
{
"fieldname": "secondary_items_tab",
"fieldtype": "Tab Break",
"label": "Secondary Items"
},
{
"fieldname": "cost_allocation_section",
"fieldtype": "Section Break",
"label": "Cost Allocation"
},
{
"fieldname": "column_break_srby",
"fieldtype": "Column Break"
},
{
"fieldname": "cost_allocation",
"fieldtype": "Currency",
"label": "Cost Allocation",
"non_negative": 1,
"options": "currency",
"read_only": 1
},
{
"default": "100",
"fieldname": "cost_allocation_per",
"fieldtype": "Percent",
"label": "% Cost Allocation",
"non_negative": 1
} }
], ],
"icon": "fa fa-sitemap", "icon": "fa fa-sitemap",
@@ -713,7 +732,7 @@
"image_field": "image", "image_field": "image",
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-02-06 17:23:15.255301", "modified": "2026-02-26 14:13:34.040181",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM", "name": "BOM",

View File

@@ -113,19 +113,21 @@ class BOM(WebsiteGenerator):
from erpnext.manufacturing.doctype.bom_explosion_item.bom_explosion_item import BOMExplosionItem from erpnext.manufacturing.doctype.bom_explosion_item.bom_explosion_item import BOMExplosionItem
from erpnext.manufacturing.doctype.bom_item.bom_item import BOMItem from erpnext.manufacturing.doctype.bom_item.bom_item import BOMItem
from erpnext.manufacturing.doctype.bom_operation.bom_operation import BOMOperation from erpnext.manufacturing.doctype.bom_operation.bom_operation import BOMOperation
from erpnext.manufacturing.doctype.bom_scrap_item.bom_scrap_item import BOMScrapItem from erpnext.manufacturing.doctype.bom_secondary_item.bom_secondary_item import BOMSecondaryItem
allow_alternative_item: DF.Check allow_alternative_item: DF.Check
amended_from: DF.Link | None amended_from: DF.Link | None
base_operating_cost: DF.Currency base_operating_cost: DF.Currency
base_raw_material_cost: DF.Currency base_raw_material_cost: DF.Currency
base_scrap_material_cost: DF.Currency base_secondary_items_cost: DF.Currency
base_total_cost: DF.Currency base_total_cost: DF.Currency
bom_creator: DF.Link | None bom_creator: DF.Link | None
bom_creator_item: DF.Data | None bom_creator_item: DF.Data | None
buying_price_list: DF.Link | None buying_price_list: DF.Link | None
company: DF.Link company: DF.Link
conversion_rate: DF.Float conversion_rate: DF.Float
cost_allocation: DF.Currency
cost_allocation_per: DF.Percent
currency: DF.Link currency: DF.Link
default_source_warehouse: DF.Link | None default_source_warehouse: DF.Link | None
default_target_warehouse: DF.Link | None default_target_warehouse: DF.Link | None
@@ -155,8 +157,8 @@ class BOM(WebsiteGenerator):
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"] rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"]
route: DF.SmallText | None route: DF.SmallText | None
routing: DF.Link | None routing: DF.Link | None
scrap_items: DF.Table[BOMScrapItem] secondary_items: DF.Table[BOMSecondaryItem]
scrap_material_cost: DF.Currency secondary_items_cost: DF.Currency
set_rate_of_sub_assembly_item_based_on_bom: DF.Check set_rate_of_sub_assembly_item_based_on_bom: DF.Check
show_in_website: DF.Check show_in_website: DF.Check
show_items: DF.Check show_items: DF.Check
@@ -284,7 +286,7 @@ class BOM(WebsiteGenerator):
self.set_plc_conversion_rate() self.set_plc_conversion_rate()
self.validate_uom_is_interger() self.validate_uom_is_interger()
self.set_bom_material_details() self.set_bom_material_details()
self.set_bom_scrap_items_detail() self.set_secondary_items_details()
self.validate_materials() self.validate_materials()
self.validate_transfer_against() self.validate_transfer_against()
self.set_routing_operations() self.set_routing_operations()
@@ -294,9 +296,12 @@ class BOM(WebsiteGenerator):
self.update_stock_qty() self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False) self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
self.set_process_loss_qty() self.set_process_loss_qty()
self.validate_scrap_items() self.validate_uoms()
self.set_default_uom() self.set_default_uom()
self.validate_semi_finished_goods() self.validate_semi_finished_goods()
self.validate_secondary_items()
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
if self.docstatus == 1: if self.docstatus == 1:
self.validate_raw_materials_of_operation() self.validate_raw_materials_of_operation()
@@ -326,6 +331,22 @@ class BOM(WebsiteGenerator):
), ),
) )
def validate_secondary_items(self):
for item in self.secondary_items:
if not item.qty:
frappe.throw(
_("Row #{0}: Quantity should be greater than 0 for {1} Item {2}").format(
item.idx, item.type, get_link_to_form("Item", item.item_code)
)
)
if item.process_loss_per >= 100:
frappe.throw(
_("Row #{0}: Process Loss Percentage should be less than 100% for {1} Item {2}").format(
item.idx, item.type, get_link_to_form("Item", item.item_code)
)
)
def validate_raw_materials_of_operation(self): def validate_raw_materials_of_operation(self):
if not self.track_semi_finished_goods or not self.operations: if not self.track_semi_finished_goods or not self.operations:
return return
@@ -401,6 +422,24 @@ class BOM(WebsiteGenerator):
doc = frappe.get_doc("BOM Creator", self.bom_creator) doc = frappe.get_doc("BOM Creator", self.bom_creator)
doc.set_status(save=True) doc.set_status(save=True)
def set_fg_cost_allocation(self):
total_secondary_items_per = 0
for item in self.secondary_items:
total_secondary_items_per += item.cost_allocation_per
if self.cost_allocation_per == 100 and total_secondary_items_per:
self.cost_allocation_per -= total_secondary_items_per
self.cost_allocation = self.raw_material_cost * (self.cost_allocation_per / 100)
def validate_total_cost_allocation(self):
total_cost_allocation_per = self.cost_allocation_per
for item in self.secondary_items:
total_cost_allocation_per += item.cost_allocation_per
if total_cost_allocation_per != 100:
frappe.throw(_("Cost allocation between finished goods and secondary items should equal 100%"))
def on_update_after_submit(self): def on_update_after_submit(self):
self.validate_bom_links() self.validate_bom_links()
self.manage_default_bom() self.manage_default_bom()
@@ -462,6 +501,7 @@ class BOM(WebsiteGenerator):
"conversion_factor": item.conversion_factor, "conversion_factor": item.conversion_factor,
"sourced_by_supplier": item.sourced_by_supplier, "sourced_by_supplier": item.sourced_by_supplier,
"do_not_explode": item.do_not_explode, "do_not_explode": item.do_not_explode,
"fetch_rate": True,
} }
) )
@@ -469,13 +509,13 @@ class BOM(WebsiteGenerator):
if not item.get(r): if not item.get(r):
item.set(r, ret[r]) item.set(r, ret[r])
def set_bom_scrap_items_detail(self): def set_secondary_items_details(self):
for item in self.get("scrap_items"): for item in self.get("secondary_items"):
args = { args = {
"item_code": item.item_code, "item_code": item.item_code,
"company": self.company, "company": self.company,
"scrap_items": True, "uom": item.uom,
"bom_no": "", "fetch_rate": False,
} }
ret = self.get_bom_material_detail(args) ret = self.get_bom_material_detail(args)
for key, value in ret.items(): for key, value in ret.items():
@@ -495,7 +535,7 @@ class BOM(WebsiteGenerator):
item = self.get_item_det(args["item_code"]) item = self.get_item_det(args["item_code"])
args["bom_no"] = args["bom_no"] or item and cstr(item["default_bom"]) or "" args["bom_no"] = args.get("bom_no") or item and cstr(item["default_bom"]) or ""
args["transfer_for_manufacture"] = ( args["transfer_for_manufacture"] = (
cstr(args.get("include_item_in_manufacturing", "")) cstr(args.get("include_item_in_manufacturing", ""))
or item or item
@@ -504,7 +544,7 @@ class BOM(WebsiteGenerator):
) )
args.update(item) args.update(item)
rate = self.get_rm_rate(args) rate = self.get_rm_rate(args) if args.get("fetch_rate") else 0
ret_item = { ret_item = {
"item_name": item and args["item_name"] or "", "item_name": item and args["item_name"] or "",
"description": item and args["description"] or "", "description": item and args["description"] or "",
@@ -546,9 +586,7 @@ class BOM(WebsiteGenerator):
if not self.rm_cost_as_per: if not self.rm_cost_as_per:
self.rm_cost_as_per = "Valuation Rate" self.rm_cost_as_per = "Valuation Rate"
if arg.get("scrap_items"): if arg:
rate = get_valuation_rate(arg)
elif arg:
# Customer Provided parts and Supplier sourced parts will have zero rate # Customer Provided parts and Supplier sourced parts will have zero rate
if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get( if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get(
"sourced_by_supplier" "sourced_by_supplier"
@@ -688,7 +726,7 @@ class BOM(WebsiteGenerator):
) )
def update_stock_qty(self): def update_stock_qty(self):
for m in self.get("items"): for m in self.get("items") + self.get("secondary_items"):
if not m.conversion_factor: if not m.conversion_factor:
m.conversion_factor = flt(get_conversion_factor(m.item_code, m.uom)["conversion_factor"]) m.conversion_factor = flt(get_conversion_factor(m.item_code, m.uom)["conversion_factor"])
if m.uom and m.qty: if m.uom and m.qty:
@@ -889,16 +927,16 @@ class BOM(WebsiteGenerator):
"""Calculate bom totals""" """Calculate bom totals"""
self.calculate_op_cost(update_hour_rate) self.calculate_op_cost(update_hour_rate)
self.calculate_rm_cost(save=save_updates) self.calculate_rm_cost(save=save_updates)
self.calculate_sm_cost(save=save_updates) self.calculate_secondary_items_costs(save=save_updates)
if save_updates: if save_updates:
# not via doc event, table is not regenerated and needs updation # not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost() self.calculate_exploded_cost()
old_cost = self.total_cost old_cost = self.total_cost
self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost self.total_cost = self.operating_cost + self.raw_material_cost - self.secondary_items_cost
self.base_total_cost = ( self.base_total_cost = (
self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost self.base_operating_cost + self.base_raw_material_cost - self.base_secondary_items_cost
) )
if self.total_cost != old_cost: if self.total_cost != old_cost:
@@ -997,29 +1035,24 @@ class BOM(WebsiteGenerator):
self.raw_material_cost = total_rm_cost self.raw_material_cost = total_rm_cost
self.base_raw_material_cost = base_total_rm_cost self.base_raw_material_cost = base_total_rm_cost
def calculate_sm_cost(self, save=False): def calculate_secondary_items_costs(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals""" """Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0 total_sm_cost = 0
base_total_sm_cost = 0 base_total_sm_cost = 0
precision = self.precision("raw_material_cost")
for d in self.get("scrap_items"): for d in self.get("secondary_items"):
d.base_rate = flt(d.rate, d.precision("rate")) * flt( if not d.is_legacy:
self.conversion_rate, self.precision("conversion_rate") d.cost = flt(self.raw_material_cost * (d.cost_allocation_per / 100), precision)
) d.base_cost = flt(d.cost * self.conversion_rate, precision)
d.amount = flt(
flt(d.rate, d.precision("rate")) * flt(d.stock_qty, d.precision("stock_qty")),
d.precision("amount"),
)
d.base_amount = flt(d.amount, d.precision("amount")) * flt(
self.conversion_rate, self.precision("conversion_rate")
)
total_sm_cost += d.amount
base_total_sm_cost += d.base_amount
if save:
d.db_update()
self.scrap_material_cost = total_sm_cost total_sm_cost += d.cost
self.base_scrap_material_cost = base_total_sm_cost base_total_sm_cost += d.base_cost
if save:
d.db_update()
self.secondary_items_cost = total_sm_cost
self.base_secondary_items_cost = base_total_sm_cost
def calculate_exploded_cost(self): def calculate_exploded_cost(self):
"Set exploded row cost from it's parent BOM." "Set exploded row cost from it's parent BOM."
@@ -1221,16 +1254,29 @@ class BOM(WebsiteGenerator):
if self.process_loss_percentage: if self.process_loss_percentage:
self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100 self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100
def validate_scrap_items(self): for item in self.secondary_items:
must_be_whole_number = frappe.get_value("UOM", self.uom, "must_be_whole_number") item.process_loss_qty = flt(
item.stock_qty * (item.process_loss_per / 100), self.precision("quantity")
)
if self.process_loss_percentage and self.process_loss_percentage > 100: def validate_uoms(self):
self.validate_uom(self.item, self.uom, self.process_loss_percentage, self.process_loss_qty)
for item in self.secondary_items:
self.validate_uom(item.item_code, item.stock_uom, item.process_loss_per, item.process_loss_qty)
def validate_uom(self, item_code, uom, process_loss_per, process_loss_qty):
must_be_whole_number = frappe.get_value("UOM", uom, "must_be_whole_number")
if process_loss_per and process_loss_per > 100:
frappe.throw(_("Process Loss Percentage cannot be greater than 100")) frappe.throw(_("Process Loss Percentage cannot be greater than 100"))
if self.process_loss_qty and must_be_whole_number and self.process_loss_qty % 1 != 0: if process_loss_qty and must_be_whole_number and process_loss_qty % 1 != 0:
msg = f"Item: {frappe.bold(self.item)} with Stock UOM: {frappe.bold(self.uom)} can't have fractional process loss qty as UOM {frappe.bold(self.uom)} is a whole Number." msg = f"Item: {frappe.bold(item_code)} with Stock UOM: {frappe.bold(uom)} can't have fractional process loss qty as UOM {frappe.bold(uom)} is a whole Number."
frappe.throw(msg, title=_("Invalid Process Loss Configuration")) frappe.throw(msg, title=_("Invalid Process Loss Configuration"))
def has_scrap_items(self):
return any(d.get("type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items"))
def get_bom_item_rate(args, bom_doc): def get_bom_item_rate(args, bom_doc):
if bom_doc.rm_cost_as_per == "Valuation Rate": if bom_doc.rm_cost_as_per == "Valuation Rate":
@@ -1332,7 +1378,7 @@ def get_bom_items_as_dict(
company, company,
qty=1, qty=1,
fetch_exploded=1, fetch_exploded=1,
fetch_scrap_items=0, fetch_secondary_items=0,
include_non_stock_items=False, include_non_stock_items=False,
fetch_qty_in_stock_uom=True, fetch_qty_in_stock_uom=True,
): ):
@@ -1343,7 +1389,7 @@ def get_bom_items_as_dict(
fetch_exploded = 0 fetch_exploded = 0
group_by_cond = "group by item_code, operation_row_id, stock_uom" group_by_cond = "group by item_code, operation_row_id, stock_uom"
if fetch_scrap_items: if fetch_secondary_items:
fetch_exploded = 0 fetch_exploded = 0
group_by_cond = "group by item_code" group_by_cond = "group by item_code"
@@ -1355,8 +1401,6 @@ def get_bom_items_as_dict(
sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * %(qty)s as qty, sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * %(qty)s as qty,
item.image, item.image,
bom.project, bom.project,
bom_item.rate,
sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount,
item.stock_uom, item.stock_uom,
item.item_group, item.item_group,
item.allow_alternative_item, item.allow_alternative_item,
@@ -1388,17 +1432,18 @@ def get_bom_items_as_dict(
group_by_cond=group_by_cond, group_by_cond=group_by_cond,
select_columns=""", bom_item.source_warehouse, bom_item.operation, select_columns=""", bom_item.source_warehouse, bom_item.operation,
bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, bom_item.sourced_by_supplier, bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, bom_item.sourced_by_supplier,
sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount,
(Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""", (Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""",
) )
items = frappe.db.sql( items = frappe.db.sql(
query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True
) )
elif fetch_scrap_items: elif fetch_secondary_items:
query = query.format( query = query.format(
table="BOM Scrap Item", table="BOM Secondary Item",
where_conditions=")", where_conditions=")",
select_columns=", item.description", select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.type, bom_item.name, bom_item.is_legacy",
is_stock_item=is_stock_item, is_stock_item=is_stock_item,
qty_field="stock_qty", qty_field="stock_qty",
group_by_cond=group_by_cond, group_by_cond=group_by_cond,
@@ -1411,8 +1456,9 @@ def get_bom_items_as_dict(
where_conditions="or bom_item.is_phantom_item)", where_conditions="or bom_item.is_phantom_item)",
is_stock_item=is_stock_item, is_stock_item=is_stock_item,
qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty", qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty",
select_columns=""", bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse, select_columns=""", bom_item.rate, bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse,
bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier,
sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount,
bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id, bom_item.is_phantom_item , bom_item.bom_no """, bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id, bom_item.is_phantom_item , bom_item.bom_no """,
group_by_cond=group_by_cond, group_by_cond=group_by_cond,
) )
@@ -1432,7 +1478,7 @@ def get_bom_items_as_dict(
company, company,
qty=item.get("qty"), qty=item.get("qty"),
fetch_exploded=fetch_exploded, fetch_exploded=fetch_exploded,
fetch_scrap_items=fetch_scrap_items, fetch_secondary_items=fetch_secondary_items,
include_non_stock_items=include_non_stock_items, include_non_stock_items=include_non_stock_items,
fetch_qty_in_stock_uom=fetch_qty_in_stock_uom, fetch_qty_in_stock_uom=fetch_qty_in_stock_uom,
) )
@@ -1482,7 +1528,7 @@ def validate_bom_no(item, bom_no):
for d in bom.items: for d in bom.items:
if d.item_code.lower() == item.lower(): if d.item_code.lower() == item.lower():
rm_item_exists = True rm_item_exists = True
for d in bom.scrap_items: for d in bom.secondary_items:
if d.item_code.lower() == item.lower(): if d.item_code.lower() == item.lower():
rm_item_exists = True rm_item_exists = True
if ( if (
@@ -1773,7 +1819,7 @@ def get_bom_diff(bom1, bom2):
identifiers = { identifiers = {
"operations": "operation", "operations": "operation",
"items": "item_code", "items": "item_code",
"scrap_items": "item_code", "secondary_items": "item_code",
"exploded_items": "item_code", "exploded_items": "item_code",
} }
@@ -1919,9 +1965,9 @@ def get_op_cost_from_sub_assemblies(bom_no, op_cost=0):
return op_cost return op_cost
def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None): def get_secondary_items_from_sub_assemblies(bom_no, company, qty, secondary_items=None):
if not scrap_items: if not secondary_items:
scrap_items = {} secondary_items = {}
bom_items = frappe.get_all( bom_items = frappe.get_all(
"BOM Item", "BOM Item",
@@ -1935,9 +1981,9 @@ def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None):
continue continue
qty = flt(row.qty) * flt(qty) qty = flt(row.qty) * flt(qty)
items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_scrap_items=1) items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_secondary_items=1)
scrap_items.update(items) secondary_items.update(items)
get_scrap_items_from_sub_assemblies(row.bom_no, company, qty, scrap_items) get_secondary_items_from_sub_assemblies(row.bom_no, company, qty, secondary_items)
return scrap_items return secondary_items

View File

@@ -895,7 +895,7 @@ def create_bom_with_process_loss_item(
if scrap_qty: if scrap_qty:
bom_doc.append( bom_doc.append(
"scrap_items", "secondary_items",
{ {
"item_code": fg_item.item_code, "item_code": fg_item.item_code,
"qty": scrap_qty, "qty": scrap_qty,

View File

@@ -36,15 +36,17 @@
"quantity": 1.0 "quantity": 1.0
}, },
{ {
"scrap_items":[ "secondary_items":[
{ {
"amount": 2000.0, "amount": 2000.0,
"doctype": "BOM Scrap Item", "doctype": "BOM Secondary Item",
"item_code": "_Test Item Home Desktop 100", "item_code": "_Test Item Home Desktop 100",
"parentfield": "scrap_items", "parentfield": "secondary_items",
"stock_qty": 1.0, "stock_qty": 1.0,
"rate": 2000.0, "rate": 2000.0,
"stock_uom": "_Test UOM" "stock_uom": "_Test UOM",
"type": "Scrap",
"is_legacy": 1
} }
], ],
"items": [ "items": [

View File

@@ -203,7 +203,9 @@ class BOMCreator(Document):
self, self,
) )
else: else:
row.rate = flt(self.get_raw_material_cost(row.item_code) * row.conversion_factor) row.rate = flt(
self.get_raw_material_cost(row.item_code) / flt(row.qty or 1) * row.conversion_factor
)
row.amount = flt(row.rate) * flt(row.qty) row.amount = flt(row.rate) * flt(row.qty)
amount += flt(row.amount) amount += flt(row.amount)
@@ -356,7 +358,6 @@ class BOMCreator(Document):
{ {
"bom_no": bom_no, "bom_no": bom_no,
"allow_alternative_item": 1, "allow_alternative_item": 1,
"allow_scrap_items": not item.get("is_phantom_item"),
"include_item_in_manufacturing": 1, "include_item_in_manufacturing": 1,
} }
) )

View File

@@ -55,7 +55,6 @@
}, },
{ {
"columns": 2, "columns": 2,
"depends_on": "eval:!doc.workstation_type",
"fieldname": "workstation", "fieldname": "workstation",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
@@ -297,7 +296,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-02-17 15:33:28.495850", "modified": "2026-03-31 17:09:48.771834",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Operation", "name": "BOM Operation",

View File

@@ -1,109 +0,0 @@
{
"actions": [],
"creation": "2016-09-26 02:19:21.642081",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"column_break_2",
"item_name",
"quantity_and_rate",
"stock_qty",
"rate",
"amount",
"column_break_6",
"stock_uom",
"base_rate",
"base_amount"
],
"fields": [
{
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"reqd": 1
},
{
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Item Name"
},
{
"fieldname": "quantity_and_rate",
"fieldtype": "Section Break",
"label": "Quantity and Rate"
},
{
"fieldname": "stock_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Qty",
"non_negative": 1,
"reqd": 1
},
{
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
"non_negative": 1,
"options": "currency"
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"options": "currency",
"read_only": 1
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{
"fieldname": "base_rate",
"fieldtype": "Currency",
"label": "Basic Rate (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "base_amount",
"fieldtype": "Currency",
"label": "Basic Amount (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
}
],
"istable": 1,
"links": [],
"modified": "2025-07-31 16:21:44.047007",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Scrap Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -0,0 +1,232 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-02-25 12:44:21.760154",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"type",
"rate",
"column_break_gres",
"is_legacy",
"section_break_sbnk",
"item_code",
"item_name",
"uom",
"column_break_atlf",
"qty",
"stock_uom",
"conversion_factor",
"stock_qty",
"section_break_yith",
"image",
"description",
"column_break_wsra",
"image_nygv",
"section_break_ielf",
"cost_allocation_per",
"process_loss_per",
"column_break_gtbl",
"cost",
"base_cost",
"process_loss_qty"
],
"fields": [
{
"depends_on": "eval:!doc.is_legacy",
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"mandatory_depends_on": "eval:!doc.is_legacy",
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good"
},
{
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"reqd": 1
},
{
"fetch_from": "item_code.item_name",
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Item Name",
"read_only": 1
},
{
"default": "0",
"fieldname": "cost",
"fieldtype": "Currency",
"label": "Cost",
"no_copy": 1,
"non_negative": 1,
"options": "currency",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "section_break_sbnk",
"fieldtype": "Section Break"
},
{
"fetch_from": "item_code.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{
"fieldname": "column_break_atlf",
"fieldtype": "Column Break"
},
{
"fieldname": "uom",
"fieldtype": "Link",
"in_list_view": 1,
"label": "UOM",
"options": "UOM",
"reqd": 1
},
{
"default": "1",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
"non_negative": 1,
"reqd": 1
},
{
"depends_on": "eval:!doc.is_legacy",
"fieldname": "section_break_ielf",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_gtbl",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_yith",
"fieldtype": "Section Break"
},
{
"fetch_from": "item_code.image",
"fieldname": "image",
"fieldtype": "Attach Image",
"hidden": 1,
"label": "Image",
"read_only": 1
},
{
"fieldname": "column_break_wsra",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_qty",
"fieldtype": "Float",
"label": "Stock Qty",
"non_negative": 1,
"read_only": 1
},
{
"fieldname": "qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Qty",
"non_negative": 1,
"reqd": 1
},
{
"default": "0",
"fieldname": "cost_allocation_per",
"fieldtype": "Percent",
"label": "Cost Allocation %",
"non_negative": 1,
"reqd": 1
},
{
"default": "0",
"fieldname": "process_loss_per",
"fieldtype": "Percent",
"label": "Process Loss %",
"non_negative": 1,
"reqd": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"depends_on": "image",
"fieldname": "image_nygv",
"fieldtype": "Image",
"options": "image",
"read_only": 1
},
{
"default": "0",
"fieldname": "base_cost",
"fieldtype": "Currency",
"hidden": 1,
"label": "Base Cost (Company Currency)",
"no_copy": 1,
"non_negative": 1,
"options": "Company:company:default_currency",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_gres",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "is_legacy",
"fieldname": "is_legacy",
"fieldtype": "Check",
"label": "Is Legacy",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "eval:doc.is_legacy",
"fieldname": "rate",
"fieldtype": "Currency",
"label": "Rate",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "process_loss_qty",
"fieldtype": "Float",
"label": "Process Loss Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1,
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-11 12:12:29.208031",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Secondary Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,11 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
# import frappe
from frappe.model.document import Document from frappe.model.document import Document
class BOMScrapItem(Document): class BOMSecondaryItem(Document):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.
@@ -14,17 +14,26 @@ class BOMScrapItem(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
amount: DF.Currency base_cost: DF.Currency
base_amount: DF.Currency conversion_factor: DF.Float
base_rate: DF.Currency cost: DF.Currency
cost_allocation_per: DF.Percent
description: DF.TextEditor | None
image: DF.AttachImage | None
is_legacy: DF.Check
item_code: DF.Link item_code: DF.Link
item_name: DF.Data | None item_name: DF.Data | None
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
process_loss_per: DF.Percent
process_loss_qty: DF.Float
qty: DF.Float
rate: DF.Currency rate: DF.Currency
stock_qty: DF.Float stock_qty: DF.Float
stock_uom: DF.Link | None stock_uom: DF.Link | None
type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
uom: DF.Link
# end: auto-generated types # end: auto-generated types
pass pass

View File

@@ -23,7 +23,7 @@ frappe.ui.form.on("Job Card", {
}; };
}); });
frm.set_query("item_code", "scrap_items", () => { frm.set_query("item_code", "secondary_items", () => {
return { return {
filters: { filters: {
disabled: 0, disabled: 0,
@@ -104,7 +104,7 @@ frappe.ui.form.on("Job Card", {
frm.doc.docstatus === 1 && frm.doc.docstatus === 1 &&
!frm.doc.is_subcontracted && !frm.doc.is_subcontracted &&
(frm.doc.skip_material_transfer || frm.doc.transferred_qty > 0) && (frm.doc.skip_material_transfer || frm.doc.transferred_qty > 0) &&
flt(frm.doc.for_quantity) + flt(frm.doc.process_loss_qty) > flt(frm.doc.manufactured_qty) flt(frm.doc.manufactured_qty) + flt(frm.doc.process_loss_qty) < flt(frm.doc.for_quantity)
) { ) {
frm.add_custom_button(__("Make Stock Entry"), () => { frm.add_custom_button(__("Make Stock Entry"), () => {
frappe.confirm( frappe.confirm(
@@ -278,8 +278,6 @@ frappe.ui.form.on("Job Card", {
frm.trigger("complete_job_card"); frm.trigger("complete_job_card");
}); });
} }
frm.trigger("make_dashboard");
} }
} }

View File

@@ -59,8 +59,8 @@
"time_logs", "time_logs",
"section_break_21", "section_break_21",
"sub_operations", "sub_operations",
"scrap_items_section", "secondary_items_section",
"scrap_items", "secondary_items",
"corrective_operation_section", "corrective_operation_section",
"for_job_card", "for_job_card",
"is_corrective_job_card", "is_corrective_job_card",
@@ -406,20 +406,6 @@
"options": "Batch", "options": "Batch",
"read_only": 1 "read_only": 1
}, },
{
"collapsible": 1,
"fieldname": "scrap_items_section",
"fieldtype": "Tab Break",
"label": "Scrap Items"
},
{
"fieldname": "scrap_items",
"fieldtype": "Table",
"label": "Scrap Items",
"no_copy": 1,
"options": "Job Card Scrap Item",
"print_hide": 1
},
{ {
"fetch_from": "operation.quality_inspection_template", "fetch_from": "operation.quality_inspection_template",
"fieldname": "quality_inspection_template", "fieldname": "quality_inspection_template",
@@ -623,12 +609,26 @@
{ {
"fieldname": "column_break_xhzg", "fieldname": "column_break_xhzg",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "secondary_items",
"fieldtype": "Table",
"label": "Secondary Items",
"no_copy": 1,
"options": "Job Card Secondary Item",
"print_hide": 1
},
{
"collapsible": 1,
"fieldname": "secondary_items_section",
"fieldtype": "Tab Break",
"label": "Secondary Items"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-02-06 18:27:03.178783", "modified": "2026-02-26 15:13:56.767070",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Job Card", "name": "Job Card",

View File

@@ -71,7 +71,9 @@ class JobCard(Document):
from erpnext.manufacturing.doctype.job_card_scheduled_time.job_card_scheduled_time import ( from erpnext.manufacturing.doctype.job_card_scheduled_time.job_card_scheduled_time import (
JobCardScheduledTime, JobCardScheduledTime,
) )
from erpnext.manufacturing.doctype.job_card_scrap_item.job_card_scrap_item import JobCardScrapItem from erpnext.manufacturing.doctype.job_card_secondary_item.job_card_secondary_item import (
JobCardSecondaryItem,
)
from erpnext.manufacturing.doctype.job_card_time_log.job_card_time_log import JobCardTimeLog from erpnext.manufacturing.doctype.job_card_time_log.job_card_time_log import JobCardTimeLog
actual_end_date: DF.Datetime | None actual_end_date: DF.Datetime | None
@@ -110,7 +112,7 @@ class JobCard(Document):
remarks: DF.SmallText | None remarks: DF.SmallText | None
requested_qty: DF.Float requested_qty: DF.Float
scheduled_time_logs: DF.Table[JobCardScheduledTime] scheduled_time_logs: DF.Table[JobCardScheduledTime]
scrap_items: DF.Table[JobCardScrapItem] secondary_items: DF.Table[JobCardSecondaryItem]
semi_fg_bom: DF.Link | None semi_fg_bom: DF.Link | None
sequence_id: DF.Int sequence_id: DF.Int
serial_and_batch_bundle: DF.Link | None serial_and_batch_bundle: DF.Link | None
@@ -199,6 +201,7 @@ class JobCard(Document):
def set_manufactured_qty(self): def set_manufactured_qty(self):
table_name = "Stock Entry" table_name = "Stock Entry"
child_name = "Stock Entry Detail"
if self.is_subcontracted: if self.is_subcontracted:
table_name = "Subcontracting Receipt Item" table_name = "Subcontracting Receipt Item"
@@ -208,8 +211,13 @@ class JobCard(Document):
if self.is_subcontracted: if self.is_subcontracted:
query = query.select(Sum(table.qty)) query = query.select(Sum(table.qty))
else: else:
query = query.select(Sum(table.fg_completed_qty)) child = frappe.qb.DocType(child_name)
query = query.where(table.purpose == "Manufacture") query = (
query.join(child)
.on(table.name == child.parent)
.select(Sum(child.transfer_qty))
.where((table.purpose == "Manufacture") & (child.is_finished_item == 1))
)
qty = query.run()[0][0] or 0.0 qty = query.run()[0][0] or 0.0
self.manufactured_qty = flt(qty) self.manufactured_qty = flt(qty)
@@ -267,25 +275,35 @@ class JobCard(Document):
row.sub_operation = row.operation row.sub_operation = row.operation
self.append("sub_operations", row) self.append("sub_operations", row)
def set_scrap_items(self): def set_secondary_items(self):
if not self.semi_fg_bom: if not self.semi_fg_bom and not self.bom_no:
return return
items_dict = get_bom_items_as_dict( items_dict = get_bom_items_as_dict(
self.semi_fg_bom, self.company, qty=self.for_quantity, fetch_exploded=0, fetch_scrap_items=1 self.semi_fg_bom or self.bom_no,
self.company,
qty=self.for_quantity,
fetch_exploded=0,
fetch_secondary_items=1,
) )
for item_code, values in items_dict.items(): for item_code, values in items_dict.items():
values = frappe._dict(values) values = frappe._dict(values)
secondary_item = {
"item_code": item_code,
"stock_qty": values.qty,
"item_name": values.item_name,
"stock_uom": values.stock_uom,
"type": values.type,
"bom_secondary_item": values.name,
}
self.append( if not values.is_legacy:
"scrap_items", secondary_item["stock_qty"] -= flt(
{ secondary_item["stock_qty"] * (values.process_loss_per / 100),
"item_code": item_code, self.precision("for_quantity"),
"stock_qty": values.qty, )
"item_name": values.item_name,
"stock_uom": values.stock_uom, self.append("secondary_items", secondary_item)
},
)
def validate_time_logs(self, save=False): def validate_time_logs(self, save=False):
self.total_time_in_mins = 0.0 self.total_time_in_mins = 0.0
@@ -1181,7 +1199,7 @@ class JobCard(Document):
def set_status(self, update_status=False): def set_status(self, update_status=False):
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
if self.finished_good and self.docstatus == 1: if self.finished_good and self.docstatus == 1:
if self.manufactured_qty >= self.for_quantity: if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
self.status = "Completed" self.status = "Completed"
elif self.transferred_qty > 0 or self.skip_material_transfer: elif self.transferred_qty > 0 or self.skip_material_transfer:
self.status = "Work In Progress" self.status = "Work In Progress"
@@ -1456,12 +1474,24 @@ class JobCard(Document):
) )
@frappe.whitelist() @frappe.whitelist()
def make_stock_entry_for_semi_fg_item(self, auto_submit=False): def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
def get_consumed_process_loss():
table = frappe.qb.DocType("Stock Entry")
query = (
frappe.qb.from_(table)
.select(Sum(table.process_loss_qty))
.where(
(table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1)
)
)
return query.run()[0][0] or 0
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
ste = ManufactureEntry( ste = ManufactureEntry(
{ {
"for_quantity": self.for_quantity - self.manufactured_qty, "for_quantity": self.for_quantity - self.manufactured_qty,
"process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0),
"job_card": self.name, "job_card": self.name,
"skip_material_transfer": self.skip_material_transfer, "skip_material_transfer": self.skip_material_transfer,
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,
@@ -1481,9 +1511,10 @@ class JobCard(Document):
wo_doc = frappe.get_doc("Work Order", self.work_order) wo_doc = frappe.get_doc("Work Order", self.work_order)
add_additional_cost(ste.stock_entry, wo_doc, self) add_additional_cost(ste.stock_entry, wo_doc, self)
ste.stock_entry.set_scrap_items() ste.stock_entry.pro_doc = frappe.get_doc("Work Order", self.work_order)
ste.stock_entry.set_secondary_items_from_job_card()
for row in ste.stock_entry.items: for row in ste.stock_entry.items:
if row.is_scrap_item and not row.t_warehouse: if (row.type or row.is_legacy_scrap_item) and not row.t_warehouse:
row.t_warehouse = self.target_warehouse row.t_warehouse = self.target_warehouse
if auto_submit: if auto_submit:

View File

@@ -882,6 +882,193 @@ class TestJobCard(ERPNextTestSuite):
s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6))
self.assertEqual(s.additional_costs[0].amount, 8) self.assertEqual(s.additional_costs[0].amount, 8)
def test_co_by_product_for_sfg_flow(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
def create_bom(raw_material, finished_good, scrap_item, submit=True):
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = finished_good
bom.quantity = 1
bom.append("items", {"item_code": raw_material, "qty": 1})
bom.append(
"secondary_items",
{
"item_code": scrap_item,
"qty": 1,
"process_loss_per": 10,
"cost_allocation_per": 5,
"type": "Scrap",
},
)
if submit:
bom.insert()
bom.submit()
return bom
rm1 = create_item("RM 1")
scrap1 = create_item("Scrap 1")
sfg = create_item("SFG 1")
sfg_bom = create_bom(rm1.name, sfg.name, scrap1.name)
rm2 = create_item("RM 2")
fg1 = create_item("FG 1")
scrap2 = create_item("Scrap 2")
scrap_extra = create_item("Scrap Extra")
fg_bom = create_bom(rm2.name, fg1.name, scrap2.name, submit=False)
fg_bom.with_operations = 1
fg_bom.track_semi_finished_goods = 1
operation1 = {
"operation": "Test Operation A",
"workstation": "_Test Workstation A",
"finished_good": sfg.name,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 30,
}
operation2 = {
"operation": "Test Operation B",
"workstation": "_Test Workstation A",
"finished_good": fg1.name,
"bom_no": fg_bom.name,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 30,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg.name, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.save()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1.name,
qty=10,
source_warehouse="Stores - _TC",
fg_warehouse="Finished Goods - _TC",
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
job_card = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Test Operation A"}, "name"
),
)
job_card.append(
"time_logs",
{
"from_time": "2009-01-01 12:06:25",
"to_time": "2009-01-01 12:37:25",
"completed_qty": job_card.for_quantity,
},
)
job_card.append(
"secondary_items", {"item_code": scrap_extra.name, "stock_qty": 5, "type": "Co-Product"}
)
job_card.submit()
for row in sfg_bom.items:
make_stock_entry(
item_code=row.item_code,
target="Stores - _TC",
qty=10,
basic_rate=100,
)
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacturing_entry.submit()
self.assertEqual(manufacturing_entry.items[2].item_code, scrap1.name)
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
self.assertEqual(manufacturing_entry.items[3].item_code, scrap_extra.name)
self.assertEqual(manufacturing_entry.items[3].type, "Co-Product")
self.assertEqual(manufacturing_entry.items[3].qty, 5)
self.assertEqual(manufacturing_entry.items[3].basic_rate, 0)
job_card = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Test Operation B"}, "name"
),
)
job_card.append(
"time_logs",
{
"from_time": "2009-02-01 12:06:25",
"to_time": "2009-02-01 12:37:25",
"completed_qty": job_card.for_quantity,
},
)
job_card.submit()
for row in fg_bom.items:
make_stock_entry(
item_code=row.item_code,
target="Stores - _TC",
qty=10,
basic_rate=100,
)
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacturing_entry.submit()
self.assertEqual(manufacturing_entry.items[2].item_code, scrap2.name)
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
def test_secondary_items_without_sfg(self):
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:
make_stock_entry(
item_code=row.item_code,
target="_Test Warehouse - _TC",
qty=10,
basic_rate=100,
)
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
job_card.append("secondary_items", {"item_code": "_Test Item", "stock_qty": 2, "type": "Scrap"})
job_card.append(
"time_logs",
{
"from_time": "2009-01-01 12:06:25",
"to_time": "2009-01-01 12:37:25",
"completed_qty": job_card.for_quantity,
},
)
job_card.save()
job_card.submit()
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_for_wo,
)
s = frappe.get_doc(make_stock_entry_for_wo(self.work_order.name, "Manufacture"))
s.submit()
self.assertEqual(s.items[3].item_code, "_Test Item")
self.assertEqual(s.items[3].transfer_qty, 2)
def create_bom_with_multiple_operations(): def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card" "Create a BOM with multiple operations and Material Transfer against Job Card"

View File

@@ -5,10 +5,12 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"type",
"description",
"column_break_3",
"item_code", "item_code",
"item_name", "item_name",
"column_break_3", "bom_secondary_item",
"description",
"quantity_and_rate", "quantity_and_rate",
"stock_qty", "stock_qty",
"column_break_6", "column_break_6",
@@ -19,7 +21,7 @@
"fieldname": "item_code", "fieldname": "item_code",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Scrap Item Code", "label": "Secondary Item Code",
"options": "Item", "options": "Item",
"reqd": 1 "reqd": 1
}, },
@@ -28,7 +30,7 @@
"fieldname": "item_name", "fieldname": "item_name",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Scrap Item Name" "label": "Secondary Item Name"
}, },
{ {
"fieldname": "column_break_3", "fieldname": "column_break_3",
@@ -65,20 +67,36 @@
"label": "Stock UOM", "label": "Stock UOM",
"options": "UOM", "options": "UOM",
"read_only": 1 "read_only": 1
},
{
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Co-Product\nBy-Product\nScrap\nAdditional Finished Good",
"reqd": 1
},
{
"fieldname": "bom_secondary_item",
"fieldtype": "Data",
"hidden": 1,
"label": "BOM Secondary Item Reference",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-07-29 13:09:57.323835", "modified": "2026-03-06 13:51:00.492621",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Job Card Scrap Item", "name": "Job Card Secondary Item",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -4,7 +4,7 @@
from frappe.model.document import Document from frappe.model.document import Document
class JobCardScrapItem(Document): class JobCardSecondaryItem(Document):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.
@@ -13,6 +13,7 @@ class JobCardScrapItem(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
bom_secondary_item: DF.Data | None
description: DF.SmallText | None description: DF.SmallText | None
item_code: DF.Link item_code: DF.Link
item_name: DF.Data | None item_name: DF.Data | None
@@ -21,6 +22,7 @@ class JobCardScrapItem(Document):
parenttype: DF.Data parenttype: DF.Data
stock_qty: DF.Float stock_qty: DF.Float
stock_uom: DF.Link | None stock_uom: DF.Link | None
type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
# end: auto-generated types # end: auto-generated types
pass pass

View File

@@ -36,7 +36,7 @@
"capacity_planning_for_days", "capacity_planning_for_days",
"mins_between_operations", "mins_between_operations",
"other_settings_section", "other_settings_section",
"set_op_cost_and_scrap_from_sub_assemblies", "set_op_cost_and_secondary_items_from_sub_assemblies",
"column_break_23", "column_break_23",
"make_serial_no_batch_from_work_order" "make_serial_no_batch_from_work_order"
], ],
@@ -202,13 +202,6 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Validate Components and Quantities Per BOM" "label": "Validate Components and Quantities Per BOM"
}, },
{
"default": "0",
"description": "To include sub-assembly costs and scrap items in Finished Goods on a work order without using a job card, when the 'Use Multi-Level BOM' option is enabled.",
"fieldname": "set_op_cost_and_scrap_from_sub_assemblies",
"fieldtype": "Check",
"label": "Set Operating Cost / Scrap Items From Sub-assemblies"
},
{ {
"default": "0", "default": "0",
"description": "Enabling this checkbox will force each Job Card Time Log to have From Time and To Time", "description": "Enabling this checkbox will force each Job Card Time Log to have From Time and To Time",
@@ -237,6 +230,13 @@
"fieldname": "allow_editing_of_items_and_quantities_in_work_order", "fieldname": "allow_editing_of_items_and_quantities_in_work_order",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Editing of Items and Quantities in Work Order" "label": "Allow Editing of Items and Quantities in Work Order"
},
{
"default": "0",
"description": "To include sub-assembly costs and secondary items in Finished Goods on a work order without using a job card, when the 'Use Multi-Level BOM' option is enabled.",
"fieldname": "set_op_cost_and_secondary_items_from_sub_assemblies",
"fieldtype": "Check",
"label": "Set Operating Cost / Secondary Items From Sub-assemblies"
} }
], ],
"hide_toolbar": 0, "hide_toolbar": 0,
@@ -244,7 +244,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-03-16 13:28:20.714576", "modified": "2026-03-20 13:28:20.714576",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Manufacturing Settings", "name": "Manufacturing Settings",

View File

@@ -32,7 +32,7 @@ class ManufacturingSettings(Document):
mins_between_operations: DF.Int mins_between_operations: DF.Int
overproduction_percentage_for_sales_order: DF.Percent overproduction_percentage_for_sales_order: DF.Percent
overproduction_percentage_for_work_order: DF.Percent overproduction_percentage_for_work_order: DF.Percent
set_op_cost_and_scrap_from_sub_assemblies: DF.Check set_op_cost_and_secondary_items_from_sub_assemblies: DF.Check
transfer_extra_materials_percentage: DF.Percent transfer_extra_materials_percentage: DF.Percent
update_bom_costs_automatically: DF.Check update_bom_costs_automatically: DF.Check
validate_components_quantities_per_bom: DF.Check validate_components_quantities_per_bom: DF.Check

View File

@@ -2875,6 +2875,7 @@ def make_bom(**args):
"company": args.company or "_Test Company", "company": args.company or "_Test Company",
"routing": args.routing, "routing": args.routing,
"with_operations": args.with_operations or 0, "with_operations": args.with_operations or 0,
"process_loss_percentage": args.process_loss_percentage or 0,
} }
) )
@@ -2896,6 +2897,23 @@ def make_bom(**args):
}, },
) )
if args.scrap_items:
for item in args.scrap_items:
item_doc = frappe.get_doc("Item", item)
bom.append(
"secondary_items",
{
"type": "Scrap",
"item_code": item,
"item_name": item,
"uom": item_doc.stock_uom,
"stock_uom": item_doc.stock_uom,
"qty": args.scrap_qty or 1,
"cost_allocation_per": args.scrap_cost_allocation_per or 10,
"process_loss_per": args.scrap_process_loss_per or 10,
},
)
if not args.do_not_save: if not args.do_not_save:
bom.insert(ignore_permissions=True) bom.insert(ignore_permissions=True)

View File

@@ -6,7 +6,7 @@ from collections import defaultdict
import frappe import frappe
from frappe.tests import timeout from frappe.tests import timeout
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc
@@ -329,7 +329,7 @@ class TestWorkOrder(ERPNextTestSuite):
cint(bin1_on_stop_production.projected_qty) + 1, cint(self.bin1_at_start.projected_qty) cint(bin1_on_stop_production.projected_qty) + 1, cint(self.bin1_at_start.projected_qty)
) )
def test_scrap_material_qty(self): def test_secondary_material_qty(self):
wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2) wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
# add raw materials to stores # add raw materials to stores
@@ -354,15 +354,15 @@ class TestWorkOrder(ERPNextTestSuite):
"Work Order", wo_order.name, ["scrap_warehouse", "qty", "produced_qty", "bom_no"], as_dict=1 "Work Order", wo_order.name, ["scrap_warehouse", "qty", "produced_qty", "bom_no"], as_dict=1
) )
scrap_item_details = get_scrap_item_details(wo_order_details.bom_no) secondary_item_details = get_secondary_item_details(wo_order_details.bom_no)
self.assertEqual(wo_order_details.produced_qty, 2) self.assertEqual(wo_order_details.produced_qty, 2)
for item in s.items: for item in s.items:
if item.bom_no and item.item_code in scrap_item_details: if item.bom_no and item.item_code in secondary_item_details:
self.assertEqual(wo_order_details.scrap_warehouse, item.t_warehouse) self.assertEqual(wo_order_details.scrap_warehouse, item.t_warehouse)
self.assertEqual( self.assertEqual(
flt(wo_order_details.qty) * flt(scrap_item_details[item.item_code]), item.qty flt(wo_order_details.qty) * flt(secondary_item_details[item.item_code]), item.qty
) )
def test_allow_overproduction(self): def test_allow_overproduction(self):
@@ -1015,7 +1015,7 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(wo.status, "Completed") self.assertEqual(wo.status, "Completed")
@timeout(seconds=60) @timeout(seconds=60)
def test_job_card_scrap_item(self): def test_job_card_secondary_item(self):
items = [ items = [
"Test FG Item for Scrap Item Test", "Test FG Item for Scrap Item Test",
"Test RM Item 1 for Scrap Item Test", "Test RM Item 1 for Scrap Item Test",
@@ -1074,7 +1074,7 @@ class TestWorkOrder(ERPNextTestSuite):
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
for row in stock_entry.items: for row in stock_entry.items:
if row.is_scrap_item: if row.type or row.is_legacy_scrap_item:
self.assertEqual(row.qty, 1) self.assertEqual(row.qty, 1)
# Partial Job Card 1 with qty 10 # Partial Job Card 1 with qty 10
@@ -1086,7 +1086,7 @@ class TestWorkOrder(ERPNextTestSuite):
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
for row in stock_entry.items: for row in stock_entry.items:
if row.is_scrap_item: if row.type or row.is_legacy_scrap_item:
self.assertEqual(row.qty, 2) self.assertEqual(row.qty, 2)
# Partial Job Card 2 with qty 10 # Partial Job Card 2 with qty 10
@@ -2134,10 +2134,12 @@ class TestWorkOrder(ERPNextTestSuite):
for row in se_doc.additional_costs: for row in se_doc.additional_costs:
self.assertEqual(row.expense_account, operating_cost_account) self.assertEqual(row.expense_account, operating_cost_account)
def test_op_cost_and_scrap_based_on_sub_assemblies(self): def test_set_op_cost_and_secondary_items_from_sub_assemblies(self):
# Make Sub Assembly BOM 1 # Make Sub Assembly BOM 1
frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies", 1) frappe.db.set_single_value(
"Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies", 1
)
items = { items = {
"Test Final FG Item": 0, "Test Final FG Item": 0,
@@ -2169,16 +2171,20 @@ class TestWorkOrder(ERPNextTestSuite):
se_doc.save() se_doc.save()
self.assertTrue(se_doc.additional_costs) self.assertTrue(se_doc.additional_costs)
scrap_items = [] secondary_items = []
for item in se_doc.items: for item in se_doc.items:
if item.is_scrap_item: if item.type or item.is_legacy_scrap_item:
scrap_items.append(item.item_code) secondary_items.append(item.item_code)
self.assertEqual(sorted(scrap_items), sorted(["Test Final Scrap Item 1", "Test Final Scrap Item 2"])) self.assertEqual(
sorted(secondary_items), sorted(["Test Final Scrap Item 1", "Test Final Scrap Item 2"])
)
for row in se_doc.additional_costs: for row in se_doc.additional_costs:
self.assertEqual(row.amount, 3000) self.assertEqual(row.amount, 3000)
frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies", 0) frappe.db.set_single_value(
"Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies", 0
)
@ERPNextTestSuite.change_settings( @ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1} "Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
@@ -2413,7 +2419,7 @@ class TestWorkOrder(ERPNextTestSuite):
stock_entry.submit() stock_entry.submit()
def test_disassembly_order_with_qty_behavior(self): def test_disassembly_order_with_qty_from_wo_behavior(self):
# Create raw material and FG item # Create raw material and FG item
raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name
@@ -2453,27 +2459,9 @@ class TestWorkOrder(ERPNextTestSuite):
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty)) se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_for_manufacture.submit() se_for_manufacture.submit()
# Simulate a disassembly stock entry # Disassembly via WO required_items path (no source_stock_entry)
disassemble_qty = 4 disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
stock_entry.append(
"items",
{
"item_code": fg_item,
"qty": disassemble_qty,
"s_warehouse": wo.fg_warehouse,
},
)
for bom_item in bom.items:
stock_entry.append(
"items",
{
"item_code": bom_item.item_code,
"qty": (bom_item.qty / bom.quantity) * disassemble_qty,
"t_warehouse": wo.source_warehouse,
},
)
wo.reload() wo.reload()
stock_entry.save() stock_entry.save()
@@ -2488,7 +2476,7 @@ class TestWorkOrder(ERPNextTestSuite):
f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}", f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}",
) )
# Assert raw materials # Assert raw materials - qty scaled from WO required_items
for item in stock_entry.items: for item in stock_entry.items:
if item.item_code == fg_item: if item.item_code == fg_item:
continue continue
@@ -2512,10 +2500,35 @@ class TestWorkOrder(ERPNextTestSuite):
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}", f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
) )
# Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path
# (first disassembly auto-set source_stock_entry since there's only one manufacture entry)
disassemble_qty_2 = 2
stock_entry_2 = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name
)
)
stock_entry_2.save()
stock_entry_2.submit()
# All rows must trace back to se_for_manufacture
for item in stock_entry_2.items:
self.assertEqual(item.against_stock_entry, se_for_manufacture.name)
self.assertTrue(item.ste_detail)
# RM qty scaled from the manufacture SE rows
rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None)
expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2
self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3)
wo.reload()
self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2)
def test_disassembly_with_multiple_manufacture_entries(self): def test_disassembly_with_multiple_manufacture_entries(self):
""" """
Test that disassembly does not create duplicate items when manufacturing Test that disassembly does not create duplicate items when manufacturing
is done in multiple batches (multiple manufacture stock entries). is done in multiple batches (multiple manufacture stock entries), including
secondary/scrap items.
Scenario: Scenario:
1. Create Work Order for 10 units 1. Create Work Order for 10 units
@@ -2524,11 +2537,19 @@ class TestWorkOrder(ERPNextTestSuite):
4. Create Disassembly for 4 units 4. Create Disassembly for 4 units
5. Verify no duplicate items in the disassembly stock entry 5. Verify no duplicate items in the disassembly stock entry
""" """
# Create RM and FG item # Create RM, scrap and FG item
raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2) bom = make_bom(
item=fg_item,
quantity=1,
raw_materials=[raw_item1, raw_item2],
rm_qty=2,
scrap_items=[scrap_item],
scrap_qty=10,
)
# Create WO # Create WO
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
@@ -2603,7 +2624,7 @@ class TestWorkOrder(ERPNextTestSuite):
f"Found duplicate items in disassembly stock entry: {duplicates}", f"Found duplicate items in disassembly stock entry: {duplicates}",
) )
expected_items = 3 # FG item + 2 raw materials expected_items = 4 # FG item + 2 raw materials + 1 scrap item
self.assertEqual( self.assertEqual(
len(stock_entry.items), len(stock_entry.items),
expected_items, expected_items,
@@ -2614,6 +2635,17 @@ class TestWorkOrder(ERPNextTestSuite):
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty) self.assertEqual(fg_item_row.qty, disassemble_qty)
# Secondary/Scrap item: should be taken from scrap warehouse in disassembly
scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None)
self.assertIsNotNone(scrap_row)
self.assertEqual(scrap_row.type, "Scrap")
self.assertTrue(scrap_row.s_warehouse)
self.assertFalse(scrap_row.t_warehouse)
self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse)
# BOM has scrap_qty=10/FG but also process_loss_per=10%, so actual scrap per FG = 9
# Total produced = 9*3 + 9*7 = 90, disassemble 4/10 → 36
self.assertEqual(scrap_row.qty, 36)
# RM quantities # RM quantities
for bom_item in bom.items: for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
@@ -2625,19 +2657,57 @@ class TestWorkOrder(ERPNextTestSuite):
msg=f"Raw material {bom_item.item_code} qty mismatch", msg=f"Raw material {bom_item.item_code} qty mismatch",
) )
# -- BOM-path disassembly (no source_stock_entry, no work_order) --
make_stock_entry_test_record(
item_code=scrap_item,
purpose="Material Receipt",
target=wo.fg_warehouse,
qty=50,
basic_rate=10,
)
bom_disassemble_qty = 2
bom_se = frappe.get_doc(
{
"doctype": "Stock Entry",
"stock_entry_type": "Disassemble",
"purpose": "Disassemble",
"from_bom": 1,
"bom_no": bom.name,
"fg_completed_qty": bom_disassemble_qty,
"from_warehouse": wo.fg_warehouse,
"to_warehouse": wo.wip_warehouse,
"company": wo.company,
"posting_date": nowdate(),
"posting_time": nowtime(),
}
)
bom_se.get_items()
bom_se.save()
bom_se.submit()
bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None)
self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly")
# Without fix 3: qty = 10 * 2 = 20; with fix 3 (process_loss_per=10%): qty = 9 * 2 = 18
self.assertEqual(
bom_scrap_row.qty,
18,
f"BOM-path disassembly must apply process_loss_per; expected 18, got {bom_scrap_row.qty}",
)
def test_disassembly_with_additional_rm_not_in_bom(self): def test_disassembly_with_additional_rm_not_in_bom(self):
""" """
Test that disassembly correctly handles additional raw materials that were Test that SE-linked disassembly includes additional raw materials
manually added during manufacturing (not part of the BOM). that were manually added during manufacturing (not part of the BOM).
Scenario: Scenario:
1. Create Work Order for 10 units with 2 raw materials in BOM 1. Create Work Order for 10 units with 2 raw materials in BOM
2. Transfer raw materials for manufacture 2. Transfer raw materials for manufacture
3. Manufacture in 2 parts (3 units, then 7 units) 3. Manufacture in 2 parts (3 units, then 7 units)
4. In each manufacture entry, manually add an extra consumable item 4. In each manufacture entry, manually add an extra consumable item
(not in BOM) in proportion to the manufactured qty 5. Disassemble 3 units linked to first manufacture entry
5. Create Disassembly for 4 units 6. Verify additional RM is included with correct proportional qty from SE1
6. Verify that the additional RM is included in disassembly with proportional qty
""" """
from erpnext.stock.doctype.stock_entry.test_stock_entry import ( from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record, make_stock_entry as make_stock_entry_test_record,
@@ -2673,9 +2743,8 @@ class TestWorkOrder(ERPNextTestSuite):
se_for_material_transfer.save() se_for_material_transfer.save()
se_for_material_transfer.submit() se_for_material_transfer.submit()
# First Manufacture Entry - 3 units # First Manufacture Entry - 3 units with additional RM
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
# Additional RM
se_manufacture1.append( se_manufacture1.append(
"items", "items",
{ {
@@ -2688,9 +2757,8 @@ class TestWorkOrder(ERPNextTestSuite):
se_manufacture1.save() se_manufacture1.save()
se_manufacture1.submit() se_manufacture1.submit()
# Second Manufacture Entry - 7 units # Second Manufacture Entry - 7 units with additional RM
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7)) se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
# AAdditional RM
se_manufacture2.append( se_manufacture2.append(
"items", "items",
{ {
@@ -2706,13 +2774,15 @@ class TestWorkOrder(ERPNextTestSuite):
wo.reload() wo.reload()
self.assertEqual(wo.produced_qty, 10) self.assertEqual(wo.produced_qty, 10)
# Disassembly for 4 units # Disassemble 3 units linked to first manufacture entry
disassemble_qty = 4 disassemble_qty = 3
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) stock_entry = frappe.get_doc(
make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name)
)
stock_entry.save() stock_entry.save()
stock_entry.submit() stock_entry.submit()
# No duplicate # No duplicates
item_counts = {} item_counts = {}
for item in stock_entry.items: for item in stock_entry.items:
item_code = item.item_code item_code = item.item_code
@@ -2725,16 +2795,15 @@ class TestWorkOrder(ERPNextTestSuite):
f"Found duplicate items in disassembly stock entry: {duplicates}", f"Found duplicate items in disassembly stock entry: {duplicates}",
) )
# Additional RM qty # Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM)
additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None) additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
self.assertIsNotNone( self.assertIsNotNone(
additional_rm_row, additional_rm_row,
f"Additional raw material {additional_rm} not found in disassembly", f"Additional raw material {additional_rm} not found in disassembly",
) )
# intentional full reversal as not part of BOM # SE1 had 3 additional RM for 3 manufactured units, disassembling all 3
# eg: dies or consumables used during manufacturing expected_additional_rm_qty = 3
expected_additional_rm_qty = 3 + 7
self.assertAlmostEqual( self.assertAlmostEqual(
additional_rm_row.qty, additional_rm_row.qty,
expected_additional_rm_qty, expected_additional_rm_qty,
@@ -2742,7 +2811,7 @@ class TestWorkOrder(ERPNextTestSuite):
msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}", msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
) )
# RM qty # BOM RM qty — scaled from SE1's rows
for bom_item in bom.items: for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None) rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
@@ -2758,6 +2827,7 @@ class TestWorkOrder(ERPNextTestSuite):
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty) self.assertEqual(fg_item_row.qty, disassemble_qty)
# FG + 2 BOM RM + 1 additional RM = 4 items
expected_items = 4 expected_items = 4
self.assertEqual( self.assertEqual(
len(stock_entry.items), len(stock_entry.items),
@@ -2765,6 +2835,282 @@ class TestWorkOrder(ERPNextTestSuite):
f"Expected {expected_items} items, found {len(stock_entry.items)}", f"Expected {expected_items} items, found {len(stock_entry.items)}",
) )
# Verify traceability
for item in stock_entry.items:
self.assertEqual(item.against_stock_entry, se_manufacture1.name)
self.assertTrue(item.ste_detail)
def test_disassembly_auto_sets_source_stock_entry(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started")
make_stock_entry_test_record(
item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100
)
se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
for item in se_transfer.items:
item.s_warehouse = wo.wip_warehouse
se_transfer.save()
se_transfer.submit()
se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_manufacture.submit()
# Disassemble without specifying source_stock_entry
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3))
stock_entry.save()
# source_stock_entry should be auto-set since only one manufacture entry
self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name)
# All items should have against_stock_entry linked
for item in stock_entry.items:
self.assertEqual(item.against_stock_entry, se_manufacture.name)
self.assertTrue(item.ste_detail)
stock_entry.submit()
def test_disassembly_batch_tracked_items(self):
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
wip_wh = "_Test Warehouse - _TC"
rm_item = make_item(
"Test Batch RM for Disassembly SB",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBRD-RM-.###",
},
).name
fg_item = make_item(
"Test Batch FG for Disassembly SB",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBRD-FG-.###",
},
).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
wo = make_wo_order_test_record(
production_item=fg_item,
qty=6,
bom_no=bom.name,
skip_transfer=1,
source_warehouse=wip_wh,
status="Not Started",
)
# Two separate RM receipts → two distinct batches (batch_1, batch_2)
rm_receipt_1 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_batch_1 = get_batch_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_1.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
rm_receipt_2 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_batch_2 = get_batch_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_2.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches")
fg_batch_1 = make_batch(frappe._dict(item=fg_item))
fg_batch_2 = make_batch(frappe._dict(item=fg_item))
# Manufacture entry 1 — 3 FG using batch_1 RM/FG
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_1.items:
if row.item_code == rm_item:
row.batch_no = rm_batch_1
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.batch_no = fg_batch_1
row.use_serial_batch_fields = 1
se_manufacture_1.save()
se_manufacture_1.submit()
# Manufacture entry 2 — 3 FG using batch_2 RM/FG
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_2.items:
if row.item_code == rm_item:
row.batch_no = rm_batch_2
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.batch_no = fg_batch_2
row.use_serial_batch_fields = 1
se_manufacture_2.save()
se_manufacture_2.submit()
# Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's
disassemble_qty = 2
stock_entry = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
)
)
stock_entry.save()
stock_entry.submit()
# FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear)
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertIsNotNone(fg_row)
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1)
self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2)
# RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear)
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
self.assertIsNotNone(rm_row)
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1)
self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2)
# RM qty: 2 FG disassembled x 2 RM per FG = 4
self.assertAlmostEqual(rm_row.qty, 4.0, places=3)
def test_disassembly_serial_tracked_items(self):
from frappe.model.naming import make_autoname
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
wip_wh = "_Test Warehouse - _TC"
rm_item = make_item(
"Test Serial RM for Disassembly SB",
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"},
).name
fg_item = make_item(
"Test Serial FG for Disassembly SB",
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"},
).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
wo = make_wo_order_test_record(
production_item=fg_item,
qty=6,
bom_no=bom.name,
skip_transfer=1,
source_warehouse=wip_wh,
status="Not Started",
)
# Two separate RM receipts → two disjoint sets of serial numbers
rm_receipt_1 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_serials_1 = get_serial_nos_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_1.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertEqual(len(rm_serials_1), 6)
rm_receipt_2 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_serials_2 = get_serial_nos_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_2.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertEqual(len(rm_serials_2), 6)
self.assertFalse(
set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets"
)
# Pre-generate two sets of FG serial numbers
series = frappe.db.get_value("Item", fg_item, "serial_no_series")
fg_serials_1 = [make_autoname(series) for _ in range(3)]
fg_serials_2 = [make_autoname(series) for _ in range(3)]
# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_1.items:
if row.item_code == rm_item:
row.serial_no = "\n".join(rm_serials_1)
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.serial_no = "\n".join(fg_serials_1)
row.use_serial_batch_fields = 1
se_manufacture_1.save()
se_manufacture_1.submit()
# Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_2.items:
if row.item_code == rm_item:
row.serial_no = "\n".join(rm_serials_2)
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.serial_no = "\n".join(fg_serials_2)
row.use_serial_batch_fields = 1
se_manufacture_2.save()
se_manufacture_2.submit()
# Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's
disassemble_qty = 2
stock_entry = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
)
)
stock_entry.save()
stock_entry.submit()
# FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertIsNotNone(fg_row)
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle)
self.assertEqual(len(fg_dasm_serials), disassemble_qty)
self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1)))
self.assertFalse(
set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials"
)
# RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
self.assertIsNotNone(rm_row)
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle)
self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2)
self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1)))
self.assertFalse(
set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials"
)
def test_components_alternate_item_for_bom_based_manufacture_entry(self): def test_components_alternate_item_for_bom_based_manufacture_entry(self):
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
@@ -3951,7 +4297,7 @@ def prepare_boms_for_sub_assembly_test():
do_not_submit=True, do_not_submit=True,
) )
bom.append("scrap_items", {"item_code": "Test Final Scrap Item 1", "qty": 1}) bom.append("secondary_items", {"item_code": "Test Final Scrap Item 1", "qty": 1, "is_legacy": 1})
bom.submit() bom.submit()
@@ -3964,7 +4310,7 @@ def prepare_boms_for_sub_assembly_test():
do_not_submit=True, do_not_submit=True,
) )
bom.append("scrap_items", {"item_code": "Test Final Scrap Item 2", "qty": 1}) bom.append("secondary_items", {"item_code": "Test Final Scrap Item 2", "qty": 1, "is_legacy": 1})
bom.submit() bom.submit()
@@ -4159,7 +4505,7 @@ def update_job_card(job_card, jc_qty=None, days=None):
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
job_card_doc = frappe.get_doc("Job Card", job_card) job_card_doc = frappe.get_doc("Job Card", job_card)
job_card_doc.set( job_card_doc.set(
"scrap_items", "secondary_items",
[ [
{"item_code": "Test RM Item 1 for Scrap Item Test", "stock_qty": 2}, {"item_code": "Test RM Item 1 for Scrap Item Test", "stock_qty": 2},
{"item_code": "Test RM Item 2 for Scrap Item Test", "stock_qty": 2}, {"item_code": "Test RM Item 2 for Scrap Item Test", "stock_qty": 2},
@@ -4199,17 +4545,17 @@ def update_job_card(job_card, jc_qty=None, days=None):
job_card_doc.submit() job_card_doc.submit()
def get_scrap_item_details(bom_no): def get_secondary_item_details(bom_no):
scrap_items = {} secondary_items = {}
for item in frappe.db.sql( for item in frappe.db.sql(
"""select item_code, stock_qty from `tabBOM Scrap Item` """select item_code, stock_qty from `tabBOM Secondary Item`
where parent = %s""", where parent = %s""",
bom_no, bom_no,
as_dict=1, as_dict=1,
): ):
scrap_items[item.item_code] = item.stock_qty secondary_items[item.item_code] = item.stock_qty
return scrap_items return secondary_items
def allow_overproduction(fieldname, percentage): def allow_overproduction(fieldname, percentage):

View File

@@ -244,13 +244,16 @@ frappe.ui.form.on("Work Order", {
}, },
toggle_items_editable(frm) { toggle_items_editable(frm) {
if (!frm.doc.__onload?.allow_editing_items) { let allow_edit = true;
frm.set_df_property("required_items", "cannot_delete_rows", true); if (!frm.doc.__onload?.allow_editing_items) allow_edit = false;
frm.set_df_property("required_items", "cannot_add_rows", true);
frm.fields_dict["required_items"].grid.update_docfield_property("item_code", "read_only", 1); frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit);
frm.fields_dict["required_items"].grid.update_docfield_property("required_qty", "read_only", 1); frm.set_df_property("required_items", "cannot_add_rows", !allow_edit);
frm.fields_dict["required_items"].grid.refresh();
} const grid = frm.fields_dict["required_items"].grid;
grid.update_docfield_property("item_code", "read_only", !allow_edit);
grid.update_docfield_property("required_qty", "read_only", !allow_edit);
grid.refresh();
}, },
hide_reserve_stock_button(frm) { hide_reserve_stock_button(frm) {
@@ -387,6 +390,7 @@ frappe.ui.form.on("Work Order", {
args: { args: {
work_order: frm.doc.name, work_order: frm.doc.name,
operations: selected_rows, operations: selected_rows,
parent_bom: frm.doc.bom_no,
}, },
callback: function () { callback: function () {
frm.reload_doc(); frm.reload_doc();
@@ -437,7 +441,7 @@ frappe.ui.form.on("Work Order", {
make_disassembly_order(frm) { make_disassembly_order(frm) {
erpnext.work_order erpnext.work_order
.show_prompt_for_qty_input(frm, "Disassemble") .show_disassembly_prompt(frm)
.then((data) => { .then((data) => {
if (flt(data.qty) <= 0) { if (flt(data.qty) <= 0) {
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>.")); frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>."));
@@ -447,11 +451,14 @@ frappe.ui.form.on("Work Order", {
work_order_id: frm.doc.name, work_order_id: frm.doc.name,
purpose: "Disassemble", purpose: "Disassemble",
qty: data.qty, qty: data.qty,
source_stock_entry: data.source_stock_entry,
}); });
}) })
.then((stock_entry) => { .then((stock_entry) => {
frappe.model.sync(stock_entry); if (stock_entry) {
frappe.set_route("Form", stock_entry.doctype, stock_entry.name); frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
}
}); });
}, },
@@ -998,6 +1005,60 @@ erpnext.work_order = {
return flt(max, precision("qty")); return flt(max, precision("qty"));
}, },
show_disassembly_prompt: function (frm) {
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
let fields = [
{
fieldtype: "Link",
label: __("Source Manufacture Entry"),
fieldname: "source_stock_entry",
options: "Stock Entry",
description: __("Optional. Select a specific manufacture entry to reverse."),
get_query: () => {
return {
filters: {
work_order: frm.doc.name,
purpose: "Manufacture",
docstatus: 1,
},
};
},
onchange: async function () {
if (!frm.disassembly_prompt) return;
let se_name = this.value;
let qty = max_qty;
if (se_name) {
qty = await frappe.xcall(
"erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
{ stock_entry_name: se_name }
);
}
frm.disassembly_prompt.set_value("qty", qty);
frm.disassembly_prompt.fields_dict.qty.set_description(__("Max: {0}", [qty]));
},
},
{
fieldtype: "Float",
label: __("Qty for {0}", [__("Disassemble")]),
fieldname: "qty",
description: __("Max: {0}", [max_qty]),
default: max_qty,
},
];
return new Promise((resolve, reject) => {
frm.disassembly_prompt = frappe.prompt(
fields,
(data) => resolve(data),
__("Disassemble"),
__("Create")
);
});
},
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) { show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty; let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;

View File

@@ -2356,7 +2356,7 @@ def check_if_scrap_warehouse_mandatory(bom_no):
if bom_no: if bom_no:
bom = frappe.get_doc("BOM", bom_no) bom = frappe.get_doc("BOM", bom_no)
if len(bom.scrap_items) > 0: if bom.has_scrap_items():
res["set_scrap_wh_mandatory"] = True res["set_scrap_wh_mandatory"] = True
return res return res
@@ -2376,6 +2376,7 @@ def make_stock_entry(
qty: float | None = None, qty: float | None = None,
target_warehouse: str | None = None, target_warehouse: str | None = None,
is_additional_transfer_entry: bool = False, is_additional_transfer_entry: bool = False,
source_stock_entry: str | None = None,
): ):
work_order = frappe.get_doc("Work Order", work_order_id) work_order = frappe.get_doc("Work Order", work_order_id)
if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
@@ -2416,10 +2417,13 @@ def make_stock_entry(
if purpose == "Disassemble": if purpose == "Disassemble":
stock_entry.from_warehouse = work_order.fg_warehouse stock_entry.from_warehouse = work_order.fg_warehouse
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
if source_stock_entry:
stock_entry.source_stock_entry = source_stock_entry
stock_entry.set_stock_entry_type() stock_entry.set_stock_entry_type()
stock_entry.is_additional_transfer_entry = is_additional_transfer_entry stock_entry.is_additional_transfer_entry = is_additional_transfer_entry
stock_entry.get_items() stock_entry.get_items()
stock_entry.set_secondary_items_from_job_card()
if purpose != "Disassemble": if purpose != "Disassemble":
stock_entry.set_serial_no_batch_for_finished_good() stock_entry.set_serial_no_batch_for_finished_good()
@@ -2427,6 +2431,26 @@ def make_stock_entry(
return stock_entry.as_dict() return stock_entry.as_dict()
@frappe.whitelist()
def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float:
se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True)
if not se:
return 0.0
filters = {
"source_stock_entry": stock_entry_name,
"purpose": "Disassemble",
"docstatus": 1,
}
if current_se_name:
filters["name"] = ("!=", current_se_name)
already_disassembled = flt(frappe.db.get_value("Stock Entry", filters, [{"SUM": "fg_completed_qty"}]))
return flt(se.fg_completed_qty) - already_disassembled
@frappe.whitelist() @frappe.whitelist()
def get_default_warehouse(company): def get_default_warehouse(company):
wip, fg, scrap = frappe.get_cached_value( wip, fg, scrap = frappe.get_cached_value(
@@ -2478,14 +2502,14 @@ def query_sales_order(doctype, txt, searchfield, start, page_len, filters) -> li
@frappe.whitelist() @frappe.whitelist()
def make_job_card(work_order, operations): def make_job_card(work_order: str, operations: str | list, parent_bom: str | None = None):
if isinstance(operations, str): if isinstance(operations, str):
operations = json.loads(operations) operations = json.loads(operations)
work_order = frappe.get_doc("Work Order", work_order) work_order = frappe.get_doc("Work Order", work_order)
for row in operations: for row in operations:
row = frappe._dict(row) row = frappe._dict(row)
row.update(get_operation_details(row.name, work_order)) row.update(get_operation_details(row.name, work_order, parent_bom))
validate_operation_data(row) validate_operation_data(row)
qty = row.get("qty") qty = row.get("qty")
@@ -2495,7 +2519,7 @@ def make_job_card(work_order, operations):
create_job_card(work_order, row, auto_create=True) create_job_card(work_order, row, auto_create=True)
def get_operation_details(name, work_order): def get_operation_details(name, work_order, parent_bom):
for row in work_order.operations: for row in work_order.operations:
if row.name == name: if row.name == name:
return { return {
@@ -2505,7 +2529,7 @@ def get_operation_details(name, work_order):
"fg_warehouse": row.fg_warehouse, "fg_warehouse": row.fg_warehouse,
"wip_warehouse": row.wip_warehouse, "wip_warehouse": row.wip_warehouse,
"finished_good": row.finished_good, "finished_good": row.finished_good,
"bom_no": row.get("bom_no"), "bom_no": row.get("bom_no") or parent_bom,
"is_subcontracted": row.get("is_subcontracted"), "is_subcontracted": row.get("is_subcontracted"),
} }
@@ -2640,8 +2664,9 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create
work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer
): ):
doc.get_required_items() doc.get_required_items()
if work_order.track_semi_finished_goods:
doc.set_scrap_items() if work_order.track_semi_finished_goods:
doc.set_secondary_items()
if auto_create: if auto_create:
doc.flags.ignore_mandatory = True doc.flags.ignore_mandatory = True

View File

@@ -472,3 +472,4 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
erpnext.patches.v16_0.enable_serial_batch_setting erpnext.patches.v16_0.enable_serial_batch_setting
erpnext.patches.v16_0.update_requested_qty_packed_item erpnext.patches.v16_0.update_requested_qty_packed_item
erpnext.patches.v16_0.remove_payables_receivables_workspace erpnext.patches.v16_0.remove_payables_receivables_workspace
erpnext.patches.v16_0.co_by_product_patch

View File

@@ -0,0 +1,104 @@
from collections import defaultdict
import frappe
from frappe.model.utils.rename_field import rename_field
def execute():
copy_doctypes()
rename_fields()
def copy_doctypes():
previous = frappe.db.auto_commit_on_many_writes
frappe.db.auto_commit_on_many_writes = True
try:
insert_into_bom()
insert_into_job_card()
if frappe.db.has_table("Subcontracting Inward Order Scrap Item"):
insert_into_subcontracting_inward()
finally:
frappe.db.auto_commit_on_many_writes = previous
def insert_into_bom():
fields = ["item_code", "item_name", "stock_uom", "stock_qty", "rate"]
data = frappe.get_all("BOM Scrap Item", {"docstatus": ("<", 2)}, ["parent", *fields])
grouped_data = defaultdict(list)
for item in data:
grouped_data[item.parent].append(item)
for parent, items in grouped_data.items():
bom = frappe.get_doc("BOM", parent)
for item in items:
secondary_item = frappe.new_doc(
"BOM Secondary Item", parent_doc=bom, parentfield="secondary_items"
)
secondary_item.update({field: item[field] for field in fields})
secondary_item.update(
{
"uom": item.stock_uom,
"conversion_factor": 1,
"qty": item.stock_qty,
"is_legacy": 1,
"type": "Scrap",
}
)
secondary_item.insert()
def insert_into_job_card():
fields = ["item_code", "item_name", "description", "stock_qty", "stock_uom"]
bulk_insert("Job Card", "Job Card Scrap Item", "Job Card Secondary Item", fields, ["type"], ["Scrap"])
def insert_into_subcontracting_inward():
fields = [
"item_code",
"fg_item_code",
"stock_uom",
"warehouse",
"reference_name",
"produced_qty",
"delivered_qty",
]
bulk_insert(
"Subcontracting Inward Order",
"Subcontracting Inward Order Scrap Item",
"Subcontracting Inward Order Secondary Item",
fields,
["type"],
["Scrap"],
)
def bulk_insert(parent_doctype, old_doctype, new_doctype, old_fields, new_fields, new_values):
data = frappe.get_all(old_doctype, {"docstatus": ("<", 2)}, ["parent", *old_fields])
grouped_data = defaultdict(list)
for item in data:
grouped_data[item.parent].append(item)
for parent, items in grouped_data.items():
parent_doc = frappe.get_doc(parent_doctype, parent)
for item in items:
secondary_item = frappe.new_doc(new_doctype, parent_doc=parent_doc, parentfield="secondary_items")
secondary_item.update({old_field: item[old_field] for old_field in old_fields})
secondary_item.update(
{new_field: new_value for new_field, new_value in zip(new_fields, new_values, strict=True)}
)
secondary_item.insert()
def rename_fields():
rename_field("BOM", "scrap_material_cost", "secondary_items_cost")
rename_field("BOM", "base_scrap_material_cost", "base_secondary_items_cost")
rename_field("Stock Entry Detail", "is_scrap_item", "is_legacy_scrap_item")
rename_field(
"Manufacturing Settings",
"set_op_cost_and_scrap_from_sub_assemblies",
"set_op_cost_and_secondary_items_from_sub_assemblies",
)
rename_field("Selling Settings", "deliver_scrap_items", "deliver_secondary_items")
rename_field("Subcontracting Receipt Item", "is_scrap_item", "is_legacy_scrap_item")
rename_field("Subcontracting Receipt Item", "scrap_cost_per_qty", "secondary_items_cost_per_qty")

View File

@@ -35,30 +35,30 @@ frappe.listview_settings["Task"] = {
}, },
gantt_custom_popup_html: function (ganttobj, task) { gantt_custom_popup_html: function (ganttobj, task) {
let html = ` let html = `
<a class="text-white mb-2 inline-block cursor-pointer" <a class="mb-2 inline-block cursor-pointer"
href="/app/task/${ganttobj.id}""> href="/app/task/${ganttobj.id}">
${ganttobj.name} ${ganttobj.name}
</a> </a>
`; `;
if (task.project) { if (task.project) {
html += `<p class="mb-1">${__("Project")}: html += `<p class="mb-1">${__("Project")}:
<a class="text-white inline-block" <a class="inline-block"
href="/app/project/${task.project}""> href="/app/project/${task.project}">
${task.project} ${task.project}
</a> </a>
</p>`; </p>`;
} }
html += `<p class="mb-1"> html += `<p class="mb-1">
${__("Progress")}: ${__("Progress")}:
<span class="text-white">${ganttobj.progress}%</span> <span>${ganttobj.progress}%</span>
</p>`; </p>`;
if (task._assign) { if (task._assign) {
const assign_list = JSON.parse(task._assign); const assign_list = JSON.parse(task._assign);
const assignment_wrapper = ` const assignment_wrapper = `
<span>Assigned to:</span> <span>Assigned to:</span>
<span class="text-white"> <span>
${assign_list.map((user) => frappe.user_info(user).fullname).join(", ")} ${assign_list.map((user) => frappe.user_info(user).fullname).join(", ")}
</span> </span>
`; `;

View File

@@ -459,8 +459,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
reference_name: frm.doc.name, reference_name: frm.doc.name,
}, },
}); });
const value = await frappe.db.get_single_value(
"Accounts Settings",
"fetch_payment_schedule_in_payment_request"
);
if (!schedules.length) { if (!value || !schedules.length) {
this.make_payment_request(); this.make_payment_request();
return; return;
} }
@@ -1851,7 +1855,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"base_operating_cost", "base_operating_cost",
"base_raw_material_cost", "base_raw_material_cost",
"base_total_cost", "base_total_cost",
"base_scrap_material_cost", "base_secondary_items_cost",
"base_totals_section", "base_totals_section",
], ],
company_currency company_currency
@@ -1869,7 +1873,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"paid_amount", "paid_amount",
"write_off_amount", "write_off_amount",
"operating_cost", "operating_cost",
"scrap_material_cost", "secondary_items_cost",
"raw_material_cost", "raw_material_cost",
"total_cost", "total_cost",
"totals_section", "totals_section",
@@ -1915,7 +1919,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"base_operating_cost", "base_operating_cost",
"base_raw_material_cost", "base_raw_material_cost",
"base_total_cost", "base_total_cost",
"base_scrap_material_cost", "base_secondary_items_cost",
"base_rounding_adjustment", "base_rounding_adjustment",
], ],
this.frm.doc.currency != company_currency this.frm.doc.currency != company_currency
@@ -1980,11 +1984,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}); });
} }
if (this.frm.doc.scrap_items && this.frm.doc.scrap_items.length > 0) { if (this.frm.doc.secondary_items && this.frm.doc.secondary_items.length > 0) {
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items"); this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "secondary_items");
this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items"); this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "secondary_items");
var item_grid = this.frm.fields_dict["scrap_items"].grid; var item_grid = this.frm.fields_dict["secondary_items"].grid;
$.each(["base_rate", "base_amount"], function (i, fname) { $.each(["base_rate", "base_amount"], function (i, fname) {
if (frappe.meta.get_docfield(item_grid.doctype, fname)) if (frappe.meta.get_docfield(item_grid.doctype, fname))
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);

View File

@@ -21,6 +21,10 @@ $.extend(erpnext, {
toggle_serial_batch_fields(frm) { toggle_serial_batch_fields(frm) {
let hide_fields = cint(frappe.user_defaults?.enable_serial_and_batch_no_for_item) === 0 ? 1 : 0; let hide_fields = cint(frappe.user_defaults?.enable_serial_and_batch_no_for_item) === 0 ? 1 : 0;
if (!hide_fields) {
return;
}
let fields = ["serial_and_batch_bundle", "use_serial_batch_fields", "serial_no", "batch_no"]; let fields = ["serial_and_batch_bundle", "use_serial_batch_fields", "serial_no", "batch_no"];
if ( if (
@@ -44,7 +48,11 @@ $.extend(erpnext, {
} }
if (["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(frm.doc.doctype)) { if (["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(frm.doc.doctype)) {
fields.push("add_serial_batch_for_rejected_qty", "rejected_serial_and_batch_bundle"); fields.push(
"add_serial_batch_for_rejected_qty",
"rejected_serial_and_batch_bundle",
"rejected_serial_no"
);
} }
let child_name = "items"; let child_name = "items";
@@ -56,6 +64,12 @@ $.extend(erpnext, {
child_name = "stock_items"; child_name = "stock_items";
} }
let sn_field = frm.fields_dict[child_name].grid.docfields.filter((d) => d.fieldname === "serial_no");
if (sn_field?.length && sn_field[0].hidden === 1) {
// Already field is hidden
return;
}
fields.forEach((field) => { fields.forEach((field) => {
if (frm.fields_dict[child_name].get_field(field)) { if (frm.fields_dict[child_name].get_field(field)) {
frm.fields_dict[child_name].grid.update_docfield_property(field, "hidden", hide_fields); frm.fields_dict[child_name].grid.update_docfield_property(field, "hidden", hide_fields);
@@ -68,7 +82,11 @@ $.extend(erpnext, {
if ( if (
frm.doc.doctype === "Subcontracting Receipt" && frm.doc.doctype === "Subcontracting Receipt" &&
!["add_serial_batch_for_rejected_qty", "rejected_serial_and_batch_bundle"].includes(field) ![
"add_serial_batch_for_rejected_qty",
"rejected_serial_and_batch_bundle",
"rejected_serial_no",
].includes(field)
) { ) {
frm.fields_dict["supplied_items"].grid.update_docfield_property( frm.fields_dict["supplied_items"].grid.update_docfield_property(
field, field,
@@ -81,12 +99,14 @@ $.extend(erpnext, {
"in_list_view", "in_list_view",
hide_fields ? 0 : 1 hide_fields ? 0 : 1
); );
frm.fields_dict["supplied_items"].grid.reset_grid();
} }
} }
}); });
if (frm.doc.doctype === "Subcontracting Receipt") {
frm.fields_dict["supplied_items"].grid.reset_grid();
}
frm.fields_dict[child_name].grid.reset_grid(); frm.fields_dict[child_name].grid.reset_grid();
}, },

View File

@@ -0,0 +1,4 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}

View File

@@ -1,8 +1,7 @@
from unittest import TestCase
import frappe import frappe
from erpnext.regional.address_template.setup import get_address_templates, update_address_template from erpnext.regional.address_template.setup import get_address_templates, update_address_template
from erpnext.tests.utils import ERPNextTestSuite
def ensure_country(country): def ensure_country(country):
@@ -14,7 +13,7 @@ def ensure_country(country):
return c return c
class TestRegionalAddressTemplate(TestCase): class TestRegionalAddressTemplate(ERPNextTestSuite):
def test_get_address_templates(self): def test_get_address_templates(self):
"""Get the countries and paths from the templates directory.""" """Get the countries and paths from the templates directory."""
templates = get_address_templates() templates = get_address_templates()

View File

@@ -173,6 +173,7 @@ class Customer(TransactionBase):
def validate(self): def validate(self):
self.flags.is_new_doc = self.is_new() self.flags.is_new_doc = self.is_new()
self.flags.old_lead = self.lead_name self.flags.old_lead = self.lead_name
self.validate_customer_group()
validate_party_accounts(self) validate_party_accounts(self)
self.validate_credit_limit_on_change() self.validate_credit_limit_on_change()
self.set_loyalty_program() self.set_loyalty_program()
@@ -356,6 +357,17 @@ class Customer(TransactionBase):
frappe.NameError, frappe.NameError,
) )
def validate_customer_group(self):
if not self.customer_group:
return
is_group = frappe.db.get_value("Customer Group", self.customer_group, "is_group")
if is_group:
frappe.throw(
_("Cannot select a Group type Customer Group. Please select a non-group Customer Group."),
title=_("Invalid Customer Group"),
)
def validate_credit_limit_on_change(self): def validate_credit_limit_on_change(self):
if self.get("__islocal") or not self.credit_limits: if self.get("__islocal") or not self.credit_limits:
return return

View File

@@ -63,6 +63,13 @@ frappe.ui.form.on("Sales Order", {
}); });
} }
}, },
transaction_date(frm) {
prevent_past_delivery_dates(frm);
frm.set_value("delivery_date", "");
frm.doc.items.forEach((d) => {
frappe.model.set_value(d.doctype, d.name, "delivery_date", "");
});
},
refresh: function (frm) { refresh: function (frm) {
frm.fields_dict["items"].grid.update_docfield_property( frm.fields_dict["items"].grid.update_docfield_property(

View File

@@ -10,7 +10,6 @@ from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.tests import change_settings from frappe.tests import change_settings
from frappe.utils import add_days, flt, nowdate, today from frappe.utils import add_days, flt, nowdate, today
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.controllers.accounts_controller import InvalidQtyError, get_due_date, update_child_qty_rate from erpnext.controllers.accounts_controller import InvalidQtyError, get_due_date, update_child_qty_rate
from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import ( from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
make_maintenance_schedule, make_maintenance_schedule,
@@ -35,10 +34,7 @@ from erpnext.stock.get_item_details import get_bin_details
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite): class TestSalesOrder(ERPNextTestSuite):
def setUp(self):
self.create_customer("_Test Customer Credit")
@ERPNextTestSuite.change_settings( @ERPNextTestSuite.change_settings(
"Stock Settings", "Stock Settings",
{ {
@@ -2439,7 +2435,7 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
def test_credit_limit_on_so_reopning(self): def test_credit_limit_on_so_reopning(self):
# set credit limit # set credit limit
company = "_Test Company" company = "_Test Company"
customer = frappe.get_doc("Customer", self.customer) customer = frappe.get_doc("Customer", "_Test Customer")
customer.credit_limits = [] customer.credit_limits = []
customer.append( customer.append(
"credit_limits", {"company": company, "credit_limit": 1000, "bypass_credit_limit_check": False} "credit_limits", {"company": company, "credit_limit": 1000, "bypass_credit_limit_check": False}
@@ -2447,35 +2443,33 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
customer.save() customer.save()
so1 = make_sales_order(qty=9, rate=100, do_not_submit=True) so1 = make_sales_order(qty=9, rate=100, do_not_submit=True)
so1.customer = self.customer so1.customer = customer.name
so1.save().submit() so1.save().submit()
so1.update_status("Closed") so1.update_status("Closed")
so2 = make_sales_order(qty=9, rate=100, do_not_submit=True) so2 = make_sales_order(qty=9, rate=100, do_not_submit=True)
so2.customer = self.customer so2.customer = customer.name
so2.save().submit() so2.save().submit()
self.assertRaises(frappe.ValidationError, so1.update_status, "Draft") self.assertRaises(frappe.ValidationError, so1.update_status, "Draft")
@ERPNextTestSuite.change_settings("Stock Settings", {"enable_stock_reservation": True}) @ERPNextTestSuite.change_settings("Stock Settings", {"enable_stock_reservation": True})
def test_warehouse_mapping_based_on_stock_reservation(self): def test_warehouse_mapping_based_on_stock_reservation(self):
self.create_company(company_name="Glass Ceiling", abbr="GC") warehouse = "Stores - _TC"
self.create_item("Lamy Safari 2", True, self.warehouse_stores, self.company, 2000) warehouse_finished = "Finished Goods - _TC"
self.create_customer()
self.clear_old_entries()
so = frappe.new_doc("Sales Order") so = frappe.new_doc("Sales Order")
so.company = self.company so.company = "_Test Company"
so.customer = self.customer so.customer = "_Test Customer"
so.transaction_date = today() so.transaction_date = today()
so.append( so.append(
"items", "items",
{ {
"item_code": self.item, "item_code": "_Test Item",
"qty": 10, "qty": 10,
"rate": 2000, "rate": 2000,
"warehouse": self.warehouse_stores, "warehouse": "Stores - _TC",
"delivery_date": today(), "delivery_date": today(),
}, },
) )
@@ -2485,12 +2479,12 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
se = frappe.get_doc( se = frappe.get_doc(
{ {
"doctype": "Stock Entry", "doctype": "Stock Entry",
"company": self.company, "company": "_Test Company",
"stock_entry_type": "Material Receipt", "stock_entry_type": "Material Receipt",
"posting_date": today(), "posting_date": today(),
"items": [ "items": [
{"item_code": self.item, "t_warehouse": self.warehouse_stores, "qty": 5}, {"item_code": "_Test Item", "t_warehouse": warehouse, "qty": 5},
{"item_code": self.item, "t_warehouse": self.warehouse_finished_goods, "qty": 5}, {"item_code": "_Test Item", "t_warehouse": warehouse_finished, "qty": 5},
], ],
} }
) )
@@ -2503,7 +2497,7 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
{ {
"sales_order_item": itm.name, "sales_order_item": itm.name,
"item_code": itm.item_code, "item_code": itm.item_code,
"warehouse": self.warehouse_stores, "warehouse": warehouse,
"qty_to_reserve": 2, "qty_to_reserve": 2,
} }
] ]
@@ -2513,7 +2507,7 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
{ {
"sales_order_item": itm.name, "sales_order_item": itm.name,
"item_code": itm.item_code, "item_code": itm.item_code,
"warehouse": self.warehouse_finished_goods, "warehouse": warehouse_finished,
"qty_to_reserve": 3, "qty_to_reserve": 3,
} }
] ]
@@ -2523,31 +2517,31 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True})
self.assertEqual(2, len(dn.items)) self.assertEqual(2, len(dn.items))
self.assertEqual(dn.items[0].qty, 2) self.assertEqual(dn.items[0].qty, 2)
self.assertEqual(dn.items[0].warehouse, self.warehouse_stores) self.assertEqual(dn.items[0].warehouse, warehouse)
self.assertEqual(dn.items[1].qty, 3) self.assertEqual(dn.items[1].qty, 3)
self.assertEqual(dn.items[1].warehouse, self.warehouse_finished_goods) self.assertEqual(dn.items[1].warehouse, warehouse_finished)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
warehouse = create_warehouse("Test Warehouse 1", company=self.company) warehouse = create_warehouse("Test Warehouse 1", company="_Test Company")
make_stock_entry( make_stock_entry(
item_code=self.item, item_code="_Test Item",
target=warehouse, target=warehouse,
qty=5, qty=5,
company=self.company, company="_Test Company",
) )
so = frappe.new_doc("Sales Order") so = frappe.new_doc("Sales Order")
so.reserve_stock = 1 so.reserve_stock = 1
so.company = self.company so.company = "_Test Company"
so.customer = self.customer so.customer = "_Test Customer"
so.transaction_date = today() so.transaction_date = today()
so.currency = "INR" so.currency = "INR"
so.append( so.append(
"items", "items",
{ {
"item_code": self.item, "item_code": "_Test Item",
"qty": 5, "qty": 5,
"rate": 2000, "rate": 2000,
"warehouse": warehouse, "warehouse": warehouse,

View File

@@ -343,7 +343,8 @@
"fieldname": "discount_amount", "fieldname": "discount_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Discount Amount", "label": "Discount Amount",
"options": "currency" "options": "currency",
"print_hide": 1
}, },
{ {
"depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount", "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount",
@@ -503,12 +504,14 @@
{ {
"fieldname": "weight_per_unit", "fieldname": "weight_per_unit",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Weight Per Unit" "label": "Weight Per Unit",
"print_hide": 1
}, },
{ {
"fieldname": "total_weight", "fieldname": "total_weight",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Total Weight", "label": "Total Weight",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -822,6 +825,7 @@
"label": "Rate of Stock UOM", "label": "Rate of Stock UOM",
"no_copy": 1, "no_copy": 1,
"options": "currency", "options": "currency",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -830,6 +834,7 @@
"fieldname": "grant_commission", "fieldname": "grant_commission",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Grant Commission", "label": "Grant Commission",
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -837,6 +842,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Picked Qty (in Stock UOM)", "label": "Picked Qty (in Stock UOM)",
"no_copy": 1, "no_copy": 1,
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -910,6 +916,7 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Production Plan Qty", "label": "Production Plan Qty",
"no_copy": 1, "no_copy": 1,
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -926,7 +933,8 @@
"fieldname": "distributed_discount_amount", "fieldname": "distributed_discount_amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Distributed Discount Amount", "label": "Distributed Discount Amount",
"options": "currency" "options": "currency",
"print_hide": 1
}, },
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
@@ -995,6 +1003,7 @@
"label": "Subcontracted Quantity", "label": "Subcontracted Quantity",
"no_copy": 1, "no_copy": 1,
"non_negative": 1, "non_negative": 1,
"print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@@ -1010,7 +1019,8 @@
"fieldname": "fg_item_qty", "fieldname": "fg_item_qty",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Finished Good Qty", "label": "Finished Good Qty",
"mandatory_depends_on": "eval:parent.is_subcontracted" "mandatory_depends_on": "eval:parent.is_subcontracted",
"print_hide": 1
}, },
{ {
"fieldname": "requested_qty", "fieldname": "requested_qty",
@@ -1025,7 +1035,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-02-21 16:39:00.200328", "modified": "2026-02-22 16:40:00.200328",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order Item", "name": "Sales Order Item",

View File

@@ -49,7 +49,7 @@
"section_break_zwh6", "section_break_zwh6",
"allow_delivery_of_overproduced_qty", "allow_delivery_of_overproduced_qty",
"column_break_mla9", "column_break_mla9",
"deliver_scrap_items" "deliver_secondary_items"
], ],
"fields": [ "fields": [
{ {
@@ -260,13 +260,6 @@
"fieldname": "column_break_mla9", "fieldname": "column_break_mla9",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"default": "0",
"description": "If enabled, the Scrap Item generated against a Finished Good will also be added in the Stock Entry when delivering that Finished Good.",
"fieldname": "deliver_scrap_items",
"fieldtype": "Check",
"label": "Deliver Scrap Items"
},
{ {
"fieldname": "item_price_tab", "fieldname": "item_price_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
@@ -320,6 +313,13 @@
"fieldname": "enable_utm", "fieldname": "enable_utm",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable UTM" "label": "Enable UTM"
},
{
"default": "0",
"description": "If enabled, the Secondary Items generated against a Finished Good will also be added in the Stock Entry when delivering that Finished Good.",
"fieldname": "deliver_secondary_items",
"fieldtype": "Check",
"label": "Deliver Secondary Items"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,

View File

@@ -41,7 +41,7 @@ class SellingSettings(Document):
blanket_order_allowance: DF.Float blanket_order_allowance: DF.Float
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"] cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
customer_group: DF.Link | None customer_group: DF.Link | None
deliver_scrap_items: DF.Check deliver_secondary_items: DF.Check
dn_required: DF.Literal["No", "Yes"] dn_required: DF.Literal["No", "Yes"]
dont_reserve_sales_order_qty_on_sales_return: DF.Check dont_reserve_sales_order_qty_on_sales_return: DF.Check
editable_bundle_item_rates: DF.Check editable_bundle_item_rates: DF.Check

View File

@@ -820,7 +820,7 @@ class Company(NestedSet):
boms = frappe.db.sql_list("select name from tabBOM where company=%s", self.name) boms = frappe.db.sql_list("select name from tabBOM where company=%s", self.name)
if boms: if boms:
frappe.db.sql("delete from tabBOM where company=%s", self.name) frappe.db.sql("delete from tabBOM where company=%s", self.name)
for dt in ("BOM Operation", "BOM Item", "BOM Scrap Item", "BOM Explosion Item"): for dt in ("BOM Operation", "BOM Item", "BOM Secondary Item", "BOM Explosion Item"):
frappe.db.sql( frappe.db.sql(
"delete from `tab{}` where parent in ({})".format(dt, ", ".join(["%s"] * len(boms))), "delete from `tab{}` where parent in ({})".format(dt, ", ".join(["%s"] * len(boms))),
tuple(boms), tuple(boms),

View File

@@ -301,7 +301,7 @@ class Employee(NestedSet):
frappe.throw(_("User {0} does not exist").format(self.user_id)) frappe.throw(_("User {0} does not exist").format(self.user_id))
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0: if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
frappe.set_value("User", self.user_id, "enabled", not enabled) frappe.db.set_value("User", self.user_id, "enabled", not enabled)
def validate_duplicate_user_id(self): def validate_duplicate_user_id(self):
Employee = frappe.qb.DocType("Employee") Employee = frappe.qb.DocType("Employee")

Some files were not shown because too many files have changed in this diff Show More