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",
get_tree_nodes: "erpnext.accounts.utils.get_children",
on_get_node: function (nodes, deep = false) {
if (frappe.boot.user.can_read.indexOf("GL Entry") == -1) return;
on_node_render: function (node, deep) {
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 = [];
if (deep) {
// in case of `get_all_nodes`
accounts = nodes.reduce((acc, node) => [...acc, ...node.data], []);
} else {
accounts = nodes;
}
// 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);
frappe.db.get_single_value("Accounts Settings", "show_balance_in_coa").then((value) => {
if (value) {
const get_balances = frappe.call({
method: "erpnext.accounts.utils.get_account_balances",
args: {
accounts: accounts,
company: cur_tree.args.company,
include_default_fb_balances: true,
},
});
get_balances.then((r) => {
if (!r.message || r.message.length == 0) return;
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 (account.balance !== undefined) {
node.parent && node.parent.find(".balance-area").remove();
$(
'<span class="balance-area pull-right">' +
(account.account_currency != account.company_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",
menu_items: [

View File

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

View File

@@ -65,6 +65,7 @@
"payment_options_section",
"enable_loyalty_point_program",
"column_break_ctam",
"fetch_payment_schedule_in_payment_request",
"invoicing_settings_tab",
"accounts_transactions_settings_section",
"over_billing_allowance",
@@ -688,6 +689,19 @@
"fieldname": "enable_accounting_dimensions",
"fieldtype": "Check",
"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,
@@ -697,7 +711,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-27 01:04:09.415288",
"modified": "2026-03-30 07:32:58.182018",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -73,6 +73,7 @@ class AccountsSettings(Document):
enable_loyalty_point_program: DF.Check
enable_party_matching: DF.Check
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
general_ledger_remarks_length: DF.Int
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
class TestAdvancePaymentLedgerEntry(AccountsTestMixin, ERPNextTestSuite):
class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
"""
Integration tests for AdvancePaymentLedgerEntry.
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
class TestBankReconciliationTool(AccountsTestMixin, ERPNextTestSuite):
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()

View File

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

View File

@@ -2,10 +2,11 @@
# See license.txt
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):
"""A fee that's part of a withdrawal cannot be bigger than the
withdrawal itself."""

View File

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

View File

@@ -3,6 +3,8 @@
frappe.ui.form.on("Financial Report Template", {
refresh(frm) {
if (frm.is_new() || frm.doc.rows.length === 0) return;
// add custom button to view missed accounts
frm.add_custom_button(__("View Account Coverage"), function () {
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) {
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_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);
},
@@ -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 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 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 = "";
@@ -382,8 +378,13 @@ function update_formula_description(frm, data_source) {
<li><code>my_app.financial_reports.get_kpi_data</code></li>
</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>
<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>`;
} else if (data_source === "Blank Line") {
description_html = `

View File

@@ -1,6 +1,5 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:template_name",
"creation": "2025-08-02 04:44:15.184541",
"doctype": "DocType",
@@ -31,7 +30,8 @@
"in_list_view": 1,
"in_standard_filter": 1,
"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",
@@ -66,7 +66,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-11-14 00:11:03.508139",
"modified": "2026-02-23 01:04:05.797161",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Financial Report Template",

View File

@@ -32,6 +32,19 @@ class FinancialReportTemplate(Document):
template_name: DF.Data
# 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):
validator = TemplateValidator(self)
result = validator.validate()

View File

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

View File

@@ -1295,6 +1295,7 @@ class TestFilterExpressionParser(FinancialReportTemplateTestCase):
self.data_source = "Account Data"
self.idx = 1
self.reverse_sign = 0
self.advanced_filtering = True
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))
def validate_stock_accounts(self):
if self.voucher_type == "Periodic Accounting Entry":
# Skip validation for periodic accounting entry
if (
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
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
class TestLedgerHealth(AccountsTestMixin, ERPNextTestSuite):
class TestLedgerHealth(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()

View File

@@ -70,9 +70,7 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
});
});
if (frm.doc.create_missing_party) {
frm.set_df_property("party", "fieldtype", "Data", frm.doc.name, "invoices");
}
frm.trigger("update_party_labels");
},
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_name", "");
});
frm.clear_table("invoices");
frm.refresh_fields();
frm.trigger("update_party_labels");
},
make_dashboard: function (frm) {
@@ -175,6 +175,32 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
}
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", {

View File

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

View File

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

View File

@@ -824,7 +824,7 @@ frappe.ui.form.on("Payment Entry", {
paid_amount: function (frm) {
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;
if (!frm.doc.received_amount) {
if (frm.doc.paid_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.paid_amount);
} 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)
);
if (!frm.doc.paid_amount) {
if (frm.doc.received_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("paid_amount", frm.doc.received_amount);
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
class TestProcessStatementOfAccounts(AccountsTestMixin, ERPNextTestSuite):
class TestProcessStatementOfAccounts(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
letterhead = frappe.get_doc("Letter Head", "Company Letterhead - Grey")

View File

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

View File

@@ -983,6 +983,10 @@ class PurchaseInvoice(BuyingController):
if provisional_accounting_for_non_stock_items:
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"):
if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate):
if item.item_code:
@@ -1161,7 +1165,11 @@ class PurchaseInvoice(BuyingController):
)
# 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 (
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,
)
)
if (
self.auto_accounting_for_stock
and self.is_opening == "No"

View File

@@ -350,6 +350,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
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(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
@@ -368,14 +374,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# fetching the latest GL Entry with exchange gain and loss account account
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(
pi.items[0].base_net_amount - pr.items[0].base_net_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):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as create_purchase_invoice,

View File

@@ -190,6 +190,7 @@
"fieldtype": "Float",
"label": "Received Qty",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
@@ -206,7 +207,8 @@
{
"fieldname": "rejected_qty",
"fieldtype": "Float",
"label": "Rejected Qty"
"label": "Rejected Qty",
"print_hide": 1
},
{
"depends_on": "eval:doc.uom != doc.stock_uom",
@@ -226,6 +228,7 @@
"fieldtype": "Link",
"label": "UOM",
"options": "UOM",
"print_hide": 1,
"reqd": 1
},
{
@@ -261,14 +264,16 @@
"depends_on": "price_list_rate",
"fieldname": "discount_percentage",
"fieldtype": "Percent",
"label": "Discount on Price List Rate (%)"
"label": "Discount on Price List Rate (%)",
"print_hide": 1
},
{
"depends_on": "price_list_rate",
"fieldname": "discount_amount",
"fieldtype": "Currency",
"label": "Discount Amount",
"options": "currency"
"options": "currency",
"print_hide": 1
},
{
"fieldname": "col_break3",
@@ -401,12 +406,14 @@
{
"fieldname": "weight_per_unit",
"fieldtype": "Float",
"label": "Weight Per Unit"
"label": "Weight Per Unit",
"print_hide": 1
},
{
"fieldname": "total_weight",
"fieldtype": "Float",
"label": "Total Weight",
"print_hide": 1,
"read_only": 1
},
{
@@ -417,7 +424,8 @@
"fieldname": "weight_uom",
"fieldtype": "Link",
"label": "Weight UOM",
"options": "UOM"
"options": "UOM",
"print_hide": 1
},
{
"depends_on": "eval:parent.update_stock",
@@ -429,7 +437,8 @@
"fieldname": "warehouse",
"fieldtype": "Link",
"label": "Accepted Warehouse",
"options": "Warehouse"
"options": "Warehouse",
"print_hide": 1
},
{
"fieldname": "rejected_warehouse",
@@ -674,7 +683,8 @@
"fieldname": "asset_location",
"fieldtype": "Link",
"label": "Asset Location",
"options": "Location"
"options": "Location",
"print_hide": 1
},
{
"fieldname": "po_detail",
@@ -796,6 +806,7 @@
"fieldtype": "Link",
"label": "Asset Category",
"options": "Asset Category",
"print_hide": 1,
"read_only": 1
},
{
@@ -828,6 +839,7 @@
"label": "Rate of Stock UOM",
"no_copy": 1,
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
@@ -866,6 +878,7 @@
"fieldtype": "Currency",
"label": "Rate With Margin",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
@@ -892,7 +905,8 @@
"default": "1",
"fieldname": "apply_tds",
"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",
@@ -918,7 +932,8 @@
"fieldname": "wip_composite_asset",
"fieldtype": "Link",
"label": "WIP Composite Asset",
"options": "Asset"
"options": "Asset",
"print_hide": 1
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 0 && doc.docstatus === 0",
@@ -930,7 +945,8 @@
"default": "0",
"fieldname": "use_serial_batch_fields",
"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",
@@ -977,7 +993,8 @@
"fieldname": "distributed_discount_amount",
"fieldtype": "Currency",
"label": "Distributed Discount Amount",
"options": "currency"
"options": "currency",
"print_hide": 1
},
{
"fieldname": "tax_withholding_category",
@@ -991,7 +1008,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-02-15 21:07:49.455930",
"modified": "2026-03-25 18:03:33.522195",
"modified_by": "Administrator",
"module": "Accounts",
"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_request.payment_request import make_payment_request
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.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.tests.utils import ERPNextTestSuite
class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
class TestRepostAccountingLedger(ERPNextTestSuite):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
update_repost_settings()
def test_01_basic_functions(self):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
@@ -48,7 +44,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
# Test Validation Error
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.company = "_Test Company"
ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append(
@@ -65,7 +61,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
ral.save()
# 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)
gl = qb.DocType("GL Entry")
@@ -94,23 +90,23 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
def test_02_deferred_accounting_valiations(self):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
do_not_submit=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_end_date = add_days(nowdate(), 90)
si.save().submit()
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})
self.assertRaises(frappe.ValidationError, ral.save)
@@ -118,35 +114,35 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
def test_04_pcv_validation(self):
# Clear old GL entries so PCV can be submitted.
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(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
fy = get_fiscal_year(today(), company=self.company)
fy = get_fiscal_year(today(), company="_Test Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": self.company,
"company": "_Test Company",
"fiscal_year": fy[0],
"cost_center": self.cost_center,
"closing_account_head": self.retained_earnings,
"cost_center": "Main - _TC",
"closing_account_head": "Retained Earnings - _TC",
"remarks": "test",
}
)
pcv.save().submit()
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})
self.assertRaises(frappe.ValidationError, ral.save)
@@ -156,12 +152,12 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
def test_03_deletion_flag_and_preview_function(self):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
@@ -170,7 +166,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
# with deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.company = "_Test Company"
ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.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):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
@@ -195,7 +191,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
# without deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.company = "_Test Company"
ral.delete_cancelled_entries = False
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
@@ -210,16 +206,16 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
provisional_account = create_account(
account_name="Provision Account",
parent_account="Current Liabilities - _TC",
company=self.company,
company="_Test Company",
)
another_provisional_account = create_account(
account_name="Another Provision Account",
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.default_provisional_account = provisional_account
company.save()
@@ -229,7 +225,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, ERPNextTestSuite):
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)
expected_pr_gles = [
{"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.company = self.company
repost_doc.company = "_Test Company"
repost_doc.delete_cancelled_entries = True
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
repost_doc.save().submit()

View File

@@ -207,6 +207,7 @@
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"print_hide": 1,
"read_only": 1
},
{
@@ -310,7 +311,8 @@
"fieldname": "discount_amount",
"fieldtype": "Currency",
"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",
@@ -853,6 +855,7 @@
"fieldtype": "Currency",
"label": "Rate of Stock UOM",
"no_copy": 1,
"print_hide": 1,
"options": "currency",
"read_only": 1
},
@@ -869,6 +872,7 @@
"fieldname": "grant_commission",
"fieldtype": "Check",
"label": "Grant Commission",
"print_hide": 1,
"read_only": 1
},
{
@@ -926,7 +930,8 @@
"default": "0",
"fieldname": "use_serial_batch_fields",
"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",
@@ -941,7 +946,8 @@
"fieldname": "distributed_discount_amount",
"fieldtype": "Currency",
"label": "Distributed Discount Amount",
"options": "currency"
"options": "currency",
"print_hide": 1
},
{
"fieldname": "available_quantity_section",
@@ -1010,7 +1016,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-02-23 14:37:14.853941",
"modified": "2026-02-24 14:37:16.853941",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -128,6 +128,7 @@ class TaxWithholdingDetails:
self.party_type = party_type
self.party = party
self.company = company
self.tax_id = get_tax_id_for_party(self.party_type, self.party)
def get(self) -> list:
"""
@@ -161,6 +162,7 @@ class TaxWithholdingDetails:
disable_cumulative_threshold=doc.disable_cumulative_threshold,
disable_transaction_threshold=doc.disable_transaction_threshold,
taxable_amount=0,
tax_id=self.tax_id,
)
# ldc (only if valid based on posting date)
@@ -181,17 +183,13 @@ class TaxWithholdingDetails:
if self.party_type != "Supplier":
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_records = self.get_valid_ldc_records(tax_id)
ldc_records = self.get_valid_ldc_records(self.tax_id)
if not ldc_records:
return ldc_details
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
for ldc in ldc_records:
@@ -254,4 +252,5 @@ class TaxWithholdingDetails:
@allow_regional
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
import datetime
from unittest.mock import patch
import frappe
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)
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):
# 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 (
TaxWithholdingDetails,
get_tax_id_for_party,
)
@@ -646,8 +645,11 @@ class TaxWithholdingController:
# 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)
query = query.where(entry.tax_id == tax_id) if tax_id else query.where(entry.party == self.party)
query = (
query.where(entry.tax_id == category.tax_id)
if category.tax_id
else query.where(entry.party == self.party)
)
return query
@@ -686,6 +688,7 @@ class TaxWithholdingController:
"company": self.doc.company,
"party_type": self.party_type,
"party": self.party,
"tax_id": category.tax_id,
"tax_withholding_category": category.name,
"tax_withholding_group": category.tax_withholding_group,
"tax_rate": category.tax_rate,
@@ -1052,6 +1055,7 @@ class TaxWithholdingController:
"party_type": self.party_type,
"party": self.party,
"company": self.doc.company,
"tax_id": category.tax_id,
}
)
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
class TestUnreconcilePayment(AccountsTestMixin, ERPNextTestSuite):
class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()

View File

@@ -7,7 +7,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsPayable(AccountsTestMixin, ERPNextTestSuite):
class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
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
class TestAccountsReceivable(AccountsTestMixin, ERPNextTestSuite):
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()

View File

@@ -8,7 +8,7 @@ from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsReceivable(AccountsTestMixin, ERPNextTestSuite):
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.maxDiff = None
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
class TestCustomerLedgerSummary(AccountsTestMixin, ERPNextTestSuite):
class TestCustomerLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()

View File

@@ -74,6 +74,7 @@ frappe.query_reports["General Ledger"] = {
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
depends_on: "party_type",
get_data: function (txt) {
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
class TestItemWisePurchaseRegister(AccountsTestMixin, ERPNextTestSuite):
class TestItemWisePurchaseRegister(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_supplier()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -229,23 +229,3 @@ class AccountsTestMixin:
]
for doctype in doctype_list:
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
@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):
from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account

View File

@@ -130,7 +130,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"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",
"reqd": 1
},

View File

@@ -291,6 +291,30 @@ class TestPurchaseOrder(ERPNextTestSuite):
# ordered qty should decrease (back to initial) on row deletion
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):
po = create_purchase_order(item_code="_Test Item", qty=4)

View File

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

View File

@@ -167,6 +167,15 @@ def create_supplier(**args):
if not args.without_supplier_group:
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()
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
)
net_rate = item.base_net_amount
net_rate = item.qty * item.base_net_rate
if item.sales_incoming_rate: # for internal transfer
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:
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
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))
)
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:
frappe.throw(
_("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)
)
if self.doctype != "Subcontracting Inward Order":
if self.doctype not in ["Subcontracting Inward Order", "Subcontracting Receipt"]:
item.amount = item.qty * item.rate
if item.bom:
@@ -238,7 +238,7 @@ class SubcontractingController(StockController):
and self._doc_before_save
):
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
@@ -264,7 +264,7 @@ class SubcontractingController(StockController):
self.__reference_name.append(row.name)
if (row.name not in item_dict) or (
row.item_code,
row.qty + (row.get("rejected_qty") or 0),
row.received_qty,
) != item_dict[row.name]:
self.__changed_name.append(row.name)
@@ -962,7 +962,7 @@ class SubcontractingController(StockController):
):
qty = (
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
)
bom_item.main_item_code = row.item_code
@@ -1285,22 +1285,28 @@ class SubcontractingController(StockController):
if self.total_additional_costs:
if self.distribute_additional_costs_based_on == "Amount":
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:
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.amount * self.total_additional_costs) / total_amt
) / item.qty
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
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
else:
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
@frappe.whitelist()

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
import frappe
from frappe import _, bold
from frappe.query_builder import Case
@@ -18,7 +20,7 @@ class SubcontractingInwardController:
def on_submit_subcontracting_inward(self):
self.update_inward_order_item()
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.update_inward_order_status()
@@ -28,7 +30,7 @@ class SubcontractingInwardController:
self.validate_delivery()
self.validate_receive_from_customer_cancel()
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.update_inward_order_status()
@@ -239,7 +241,8 @@ class SubcontractingInwardController:
item
for item in self.get("items")
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")
]
@@ -368,7 +371,9 @@ class SubcontractingInwardController:
if self.subcontracting_inward_order:
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
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
def validate_warehouse_(self):
@@ -467,7 +472,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save()
else:
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(
"Subcontracting Inward Order Item",
item.scio_detail,
@@ -519,7 +524,7 @@ class SubcontractingInwardController:
if max_allowed_qty:
max_allowed_qty = max_allowed_qty[0]
else:
table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item")
table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
query = (
frappe.qb.from_(table)
.select((table.produced_qty - table.delivered_qty).as_("max_allowed_qty"))
@@ -538,8 +543,8 @@ class SubcontractingInwardController:
bold(
frappe.get_cached_value(
"Subcontracting Inward Order Item"
if not item.is_scrap_item
else "Subcontracting Inward Order Scrap Item",
if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item",
item.scio_detail,
"stock_uom",
)
@@ -590,9 +595,9 @@ class SubcontractingInwardController:
)
for item in [item for item in self.items if not item.is_finished_item]:
if item.is_scrap_item:
scio_scrap_item = frappe.get_value(
"Subcontracting Inward Order Scrap Item",
if item.type or item.is_legacy_scrap_item:
scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Secondary Item",
{
"docstatus": 1,
"item_code": item.item_code,
@@ -603,12 +608,13 @@ class SubcontractingInwardController:
as_dict=True,
)
if (
scio_scrap_item
and scio_scrap_item.delivered_qty > scio_scrap_item.produced_qty - item.transfer_qty
scio_secondary_item
and scio_secondary_item.delivered_qty
> scio_secondary_item.produced_qty - item.transfer_qty
):
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))
)
else:
@@ -648,8 +654,8 @@ class SubcontractingInwardController:
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.is_scrap_item
else "Subcontracting Inward Order Scrap Item"
if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item"
)
frappe.db.set_value(
doctype,
@@ -763,7 +769,11 @@ class SubcontractingInwardController:
customer_warehouse = frappe.get_cached_value(
"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(
{
(
@@ -860,24 +870,24 @@ class SubcontractingInwardController:
doc.insert()
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":
scrap_items_list = [item for item in self.items if item.is_scrap_item]
scrap_items = frappe._dict(
{
(item.item_code, item.t_warehouse): item.transfer_qty
if self._action == "submit"
else -item.transfer_qty
for item in scrap_items_list
}
)
if scrap_items:
item_codes, warehouses = zip(*list(scrap_items.keys()), strict=True)
secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item]
secondary_items = defaultdict(float)
for item in secondary_items_list:
secondary_items[(item.item_code, item.t_warehouse)] += (
item.transfer_qty if self._action == "submit" else -item.transfer_qty
)
secondary_items = frappe._dict(secondary_items)
if secondary_items:
item_codes, warehouses = zip(*list(secondary_items.keys()), strict=True)
item_codes = list(item_codes)
warehouses = list(warehouses)
result = frappe.get_all(
"Subcontracting Inward Order Scrap Item",
"Subcontracting Inward Order Secondary Item",
filters={
"item_code": ["in", item_codes],
"warehouse": ["in", warehouses],
@@ -890,7 +900,7 @@ class SubcontractingInwardController:
)
if result:
scrap_item_dict = frappe._dict(
secondary_items_dict = frappe._dict(
{
(d.item_code, d.warehouse): frappe._dict(
{"name": d.name, "produced_qty": d.produced_qty}
@@ -900,40 +910,45 @@ class SubcontractingInwardController:
)
deleted_docs = []
case_expr = Case()
table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item")
for key, value in scrap_item_dict.items():
if self._action == "cancel" and value.produced_qty - abs(scrap_items.get(key)) == 0:
table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
for key, value in secondary_items_dict.items():
if (
self._action == "cancel"
and value.produced_qty - abs(secondary_items.get(key)) == 0
):
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:
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(
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(
(table.name.isin(final_list)) & (table.docstatus == 1)
).run()
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
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]
]:
doc = frappe.new_doc(
"Subcontracting Inward Order Scrap Item",
"Subcontracting Inward Order Secondary Item",
parent=scio,
parenttype="Subcontracting Inward Order",
parentfield="scrap_items",
idx=frappe.db.count("Subcontracting Inward Order Scrap Item", {"parent": scio}) + 1,
item_code=scrap_item.item_code,
parentfield="secondary_items",
idx=frappe.db.count("Subcontracting Inward Order Secondary Item", {"parent": scio})
+ 1,
item_code=secondary_item.item_code,
fg_item_code=fg_item_code,
stock_uom=scrap_item.stock_uom,
warehouse=scrap_item.t_warehouse,
produced_qty=scrap_item.transfer_qty,
stock_uom=secondary_item.stock_uom,
warehouse=secondary_item.t_warehouse,
produced_qty=secondary_item.transfer_qty,
type=secondary_item.type,
delivered_qty=0,
reference_name=frappe.get_value(
"Work Order", self.work_order, "subcontracting_inward_order_item"
@@ -965,7 +980,7 @@ class SubcontractingInwardController:
and (
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 Scrap Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Secondary Item", item.scio_detail)
)
]
for item in items:

View File

@@ -164,6 +164,9 @@ class calculate_taxes_and_totals:
return
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:
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"):
item.amount = flt(item.rate, item.precision("amount"))
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
@@ -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 = []
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):
if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")):
return
@@ -372,9 +388,16 @@ class calculate_taxes_and_totals:
self.doc.total
) = 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:
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.net_total += item.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
diff = flt(expected_amount - actual_breakup, 5)
# TODO: fix rounding difference issues
if abs(diff) <= 0.5:
detail_row = self.doc._item_wise_tax_details[last_idx]
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):
# store tax breakup for each item
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":
item_wise_taxable_amount = flt(
current_net_amount * self.doc.conversion_rate * multiplier, tax.precision("tax_amount")
tax._running_txn_taxable_total += current_net_amount * multiplier
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:
item_wise_taxable_amount = 0.0
@@ -788,7 +825,8 @@ class calculate_taxes_and_totals:
discount_amount += total_return_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)
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.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
class TestTaxesAndTotals(AccountsTestMixin, ERPNextTestSuite):
class TestTaxesAndTotals(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_distributed_discount_amount(self):
so = make_sales_order(do_not_save=1)

View File

@@ -1,9 +1,9 @@
import json
import frappe
from frappe.utils import flt
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
from erpnext.tests.utils import ERPNextTestSuite
from erpnext.tests.utils import ERPNextTestSuite, change_settings
class TestTaxesAndTotals(ERPNextTestSuite):
@@ -124,3 +124,180 @@ class TestTaxesAndTotals(ERPNextTestSuite):
]
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",
"customer_name": uuid4(),
"customer_group": "All Customer Groups",
"customer_group": "Individual",
}
).insert()
self.supplier = frappe.get_doc(

View File

@@ -2,36 +2,17 @@ import frappe
from frappe import qb
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
class TestReactivity(AccountsTestMixin, 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()
class TestReactivity(ERPNextTestSuite):
def test_01_basic_item_details(self):
self.disable_dimensions()
# set Item Price
frappe.get_doc(
{
"doctype": "Item Price",
"item_code": self.item,
"price_list": self.price_list,
"item_code": "_Test Item",
"price_list": "Standard Selling",
"price_list_rate": 90,
"selling": True,
"rate": 90,
@@ -42,17 +23,18 @@ class TestReactivity(AccountsTestMixin, ERPNextTestSuite):
si = frappe.get_doc(
{
"doctype": "Sales Invoice",
"company": self.company,
"customer": self.customer,
"debit_to": self.debit_to,
"company": "_Test Company",
"customer": "_Test Customer",
"debit_to": "Debtors - _TC",
"posting_date": today(),
"cost_center": self.cost_center,
"cost_center": "Main - _TC",
"currency": "INR",
"conversion_rate": 1,
"selling_price_list": self.price_list,
"selling_price_list": "Standard Selling",
}
)
itm = si.append("items")
itm.item_code = self.item
itm.item_code = "_Test Item"
si.process_item_selection(itm.idx)
self.assertEqual(itm.rate, 90)

View File

@@ -501,8 +501,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr1.items[0].qty = 2
add_second_row_in_scr(scr1)
scr1.flags.ignore_mandatory = True
scr1.save()
scr1.set_missing_values()
scr1.save()
scr1.submit()
for _key, value in get_supplied_items(scr1).items():
@@ -513,8 +513,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr2.items[0].qty = 2
add_second_row_in_scr(scr2)
scr2.flags.ignore_mandatory = True
scr2.save()
scr2.set_missing_values()
scr2.save()
scr2.submit()
for _key, value in get_supplied_items(scr2).items():
@@ -523,8 +523,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr3 = make_subcontracting_receipt(sco.name)
scr3.items[0].qty = 2
scr3.flags.ignore_mandatory = True
scr3.save()
scr3.set_missing_values()
scr3.save()
scr3.submit()
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)
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):
item_dict = {}

View File

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

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.tests.utils import ERPNextTestSuite
class TestCommonCode(FrappeTestCase):
class TestCommonCode(ERPNextTestSuite):
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 () {
if (!frm.doc.operations?.length) {
frappe.throw(__("Please add Operations first."));
@@ -123,7 +138,16 @@ frappe.ui.form.on("BOM", {
},
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) => {
frm.fields_dict["operations"].grid.update_docfield_property(
@@ -131,9 +155,21 @@ frappe.ui.form.on("BOM", {
"read_only",
!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) {
@@ -173,6 +209,8 @@ frappe.ui.form.on("BOM", {
refresh(frm) {
frm.toggle_enable("item", frm.doc.__islocal);
frm.trigger("toggle_fields_for_semi_finished_goods");
frm.set_indicator_formatter("item_code", function (doc) {
if (doc.original_item) {
return doc.item_code != doc.original_item ? "orange" : "";
@@ -369,6 +407,7 @@ frappe.ui.form.on("BOM", {
reqd: 1,
default: 1,
onchange: () => {
if (!cur_dialog) return;
const { quantity, items: rm } = frm.doc;
const variant_items_map = rm.reduce((acc, item) => {
acc[item.item_code] = item.qty;
@@ -620,10 +659,10 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
}
item_code(doc, cdt, cdn) {
var scrap_items = false;
let secondary_items = false;
var child = locals[cdt][cdn];
if (child.doctype == "BOM Scrap Item") {
scrap_items = true;
if (child.doctype == "BOM Secondary Item") {
secondary_items = true;
}
if (child.bom_no) {
@@ -634,7 +673,7 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
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) {
@@ -683,7 +722,7 @@ cur_frm.cscript.is_default = function (doc) {
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) {
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,
item_code: d.item_code,
bom_no: d.bom_no != null ? d.bom_no : "",
scrap_items: scrap_items,
qty: d.qty,
stock_qty: d.stock_qty,
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,
sourced_by_supplier: d.sourced_by_supplier,
do_not_explode: d.do_not_explode,
fetch_rate: !secondary_items,
},
callback: function (r) {
$.extend(d, r.message);
refresh_field("items");
refresh_field("scrap_items");
refresh_field("secondary_items");
doc = locals[doc.doctype][doc.name];
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc);
},
freeze: true,
@@ -724,20 +762,18 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
cur_frm.cscript.qty = function (doc) {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc);
};
cur_frm.cscript.rate = function (doc, 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) {
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 {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(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.calculate_op_cost(doc);
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(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);
};
// 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
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 =
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("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) {
var d = locals[cdt][cdn];
if (!d.workstation_type) return;
if (d.workstation) {
frappe.model.set_value(cdt, cdn, "workstation", "");
}
frappe.call({
method: "frappe.client.get",
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) {
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];
row.stock_qty = (frm.doc.quantity * data.percent) / 100;
row.qty = row.stock_qty / (row.conversion_factor || 1);
refresh_field("scrap_items");
refresh_field("secondary_items");
},
__("Set Process Loss Item Quantity"),
__("Set Quantity")

View File

@@ -16,6 +16,14 @@
"allow_alternative_item",
"set_rate_of_sub_assembly_item_based_on_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",
"rm_cost_as_per",
"buying_price_list",
@@ -38,21 +46,16 @@
"operations",
"materials_section",
"items",
"scrap_section",
"scrap_items_section",
"scrap_items",
"process_loss_section",
"process_loss_percentage",
"column_break_ssj2",
"process_loss_qty",
"secondary_items_tab",
"secondary_items",
"costing",
"operating_cost",
"raw_material_cost",
"scrap_material_cost",
"secondary_items_cost",
"cb1",
"base_operating_cost",
"base_raw_material_cost",
"base_scrap_material_cost",
"base_secondary_items_cost",
"column_break_26",
"total_cost",
"base_total_cost",
@@ -298,19 +301,6 @@
"options": "BOM Item",
"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",
"fieldtype": "Tab Break",
@@ -332,15 +322,6 @@
"options": "currency",
"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",
"fieldtype": "Column Break"
@@ -362,15 +343,6 @@
"print_hide": 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",
"fieldtype": "Currency",
@@ -602,12 +574,6 @@
"fieldname": "column_break_ivyw",
"fieldtype": "Column Break"
},
{
"fieldname": "scrap_items_section",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Scrap Items"
},
{
"default": "0",
"fieldname": "fg_based_operating_cost",
@@ -706,6 +672,59 @@
"fieldname": "quality_inspection_tab",
"fieldtype": "Tab Break",
"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",
@@ -713,7 +732,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-02-06 17:23:15.255301",
"modified": "2026-02-26 14:13:34.040181",
"modified_by": "Administrator",
"module": "Manufacturing",
"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_item.bom_item import BOMItem
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
amended_from: DF.Link | None
base_operating_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
bom_creator: DF.Link | None
bom_creator_item: DF.Data | None
buying_price_list: DF.Link | None
company: DF.Link
conversion_rate: DF.Float
cost_allocation: DF.Currency
cost_allocation_per: DF.Percent
currency: DF.Link
default_source_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"]
route: DF.SmallText | None
routing: DF.Link | None
scrap_items: DF.Table[BOMScrapItem]
scrap_material_cost: DF.Currency
secondary_items: DF.Table[BOMSecondaryItem]
secondary_items_cost: DF.Currency
set_rate_of_sub_assembly_item_based_on_bom: DF.Check
show_in_website: DF.Check
show_items: DF.Check
@@ -284,7 +286,7 @@ class BOM(WebsiteGenerator):
self.set_plc_conversion_rate()
self.validate_uom_is_interger()
self.set_bom_material_details()
self.set_bom_scrap_items_detail()
self.set_secondary_items_details()
self.validate_materials()
self.validate_transfer_against()
self.set_routing_operations()
@@ -294,9 +296,12 @@ class BOM(WebsiteGenerator):
self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
self.set_process_loss_qty()
self.validate_scrap_items()
self.validate_uoms()
self.set_default_uom()
self.validate_semi_finished_goods()
self.validate_secondary_items()
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
if self.docstatus == 1:
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):
if not self.track_semi_finished_goods or not self.operations:
return
@@ -401,6 +422,24 @@ class BOM(WebsiteGenerator):
doc = frappe.get_doc("BOM Creator", self.bom_creator)
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):
self.validate_bom_links()
self.manage_default_bom()
@@ -462,6 +501,7 @@ class BOM(WebsiteGenerator):
"conversion_factor": item.conversion_factor,
"sourced_by_supplier": item.sourced_by_supplier,
"do_not_explode": item.do_not_explode,
"fetch_rate": True,
}
)
@@ -469,13 +509,13 @@ class BOM(WebsiteGenerator):
if not item.get(r):
item.set(r, ret[r])
def set_bom_scrap_items_detail(self):
for item in self.get("scrap_items"):
def set_secondary_items_details(self):
for item in self.get("secondary_items"):
args = {
"item_code": item.item_code,
"company": self.company,
"scrap_items": True,
"bom_no": "",
"uom": item.uom,
"fetch_rate": False,
}
ret = self.get_bom_material_detail(args)
for key, value in ret.items():
@@ -495,7 +535,7 @@ class BOM(WebsiteGenerator):
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"] = (
cstr(args.get("include_item_in_manufacturing", ""))
or item
@@ -504,7 +544,7 @@ class BOM(WebsiteGenerator):
)
args.update(item)
rate = self.get_rm_rate(args)
rate = self.get_rm_rate(args) if args.get("fetch_rate") else 0
ret_item = {
"item_name": item and args["item_name"] or "",
"description": item and args["description"] or "",
@@ -546,9 +586,7 @@ class BOM(WebsiteGenerator):
if not self.rm_cost_as_per:
self.rm_cost_as_per = "Valuation Rate"
if arg.get("scrap_items"):
rate = get_valuation_rate(arg)
elif arg:
if arg:
# 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(
"sourced_by_supplier"
@@ -688,7 +726,7 @@ class BOM(WebsiteGenerator):
)
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:
m.conversion_factor = flt(get_conversion_factor(m.item_code, m.uom)["conversion_factor"])
if m.uom and m.qty:
@@ -889,16 +927,16 @@ class BOM(WebsiteGenerator):
"""Calculate bom totals"""
self.calculate_op_cost(update_hour_rate)
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:
# not via doc event, table is not regenerated and needs updation
self.calculate_exploded_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_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:
@@ -997,29 +1035,24 @@ class BOM(WebsiteGenerator):
self.raw_material_cost = 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"""
total_sm_cost = 0
base_total_sm_cost = 0
precision = self.precision("raw_material_cost")
for d in self.get("scrap_items"):
d.base_rate = flt(d.rate, d.precision("rate")) * flt(
self.conversion_rate, self.precision("conversion_rate")
)
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()
for d in self.get("secondary_items"):
if not d.is_legacy:
d.cost = flt(self.raw_material_cost * (d.cost_allocation_per / 100), precision)
d.base_cost = flt(d.cost * self.conversion_rate, precision)
self.scrap_material_cost = total_sm_cost
self.base_scrap_material_cost = base_total_sm_cost
total_sm_cost += d.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):
"Set exploded row cost from it's parent BOM."
@@ -1221,16 +1254,29 @@ class BOM(WebsiteGenerator):
if self.process_loss_percentage:
self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100
def validate_scrap_items(self):
must_be_whole_number = frappe.get_value("UOM", self.uom, "must_be_whole_number")
for item in self.secondary_items:
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"))
if self.process_loss_qty and must_be_whole_number and self.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."
if process_loss_qty and must_be_whole_number and process_loss_qty % 1 != 0:
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"))
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):
if bom_doc.rm_cost_as_per == "Valuation Rate":
@@ -1332,7 +1378,7 @@ def get_bom_items_as_dict(
company,
qty=1,
fetch_exploded=1,
fetch_scrap_items=0,
fetch_secondary_items=0,
include_non_stock_items=False,
fetch_qty_in_stock_uom=True,
):
@@ -1343,7 +1389,7 @@ def get_bom_items_as_dict(
fetch_exploded = 0
group_by_cond = "group by item_code, operation_row_id, stock_uom"
if fetch_scrap_items:
if fetch_secondary_items:
fetch_exploded = 0
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,
item.image,
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.item_group,
item.allow_alternative_item,
@@ -1388,17 +1432,18 @@ def get_bom_items_as_dict(
group_by_cond=group_by_cond,
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,
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""",
)
items = frappe.db.sql(
query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True
)
elif fetch_scrap_items:
elif fetch_secondary_items:
query = query.format(
table="BOM Scrap Item",
table="BOM Secondary Item",
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,
qty_field="stock_qty",
group_by_cond=group_by_cond,
@@ -1411,8 +1456,9 @@ def get_bom_items_as_dict(
where_conditions="or bom_item.is_phantom_item)",
is_stock_item=is_stock_item,
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,
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 """,
group_by_cond=group_by_cond,
)
@@ -1432,7 +1478,7 @@ def get_bom_items_as_dict(
company,
qty=item.get("qty"),
fetch_exploded=fetch_exploded,
fetch_scrap_items=fetch_scrap_items,
fetch_secondary_items=fetch_secondary_items,
include_non_stock_items=include_non_stock_items,
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:
if d.item_code.lower() == item.lower():
rm_item_exists = True
for d in bom.scrap_items:
for d in bom.secondary_items:
if d.item_code.lower() == item.lower():
rm_item_exists = True
if (
@@ -1773,7 +1819,7 @@ def get_bom_diff(bom1, bom2):
identifiers = {
"operations": "operation",
"items": "item_code",
"scrap_items": "item_code",
"secondary_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
def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None):
if not scrap_items:
scrap_items = {}
def get_secondary_items_from_sub_assemblies(bom_no, company, qty, secondary_items=None):
if not secondary_items:
secondary_items = {}
bom_items = frappe.get_all(
"BOM Item",
@@ -1935,9 +1981,9 @@ def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None):
continue
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)
scrap_items.update(items)
items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_secondary_items=1)
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:
bom_doc.append(
"scrap_items",
"secondary_items",
{
"item_code": fg_item.item_code,
"qty": scrap_qty,

View File

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

View File

@@ -203,7 +203,9 @@ class BOMCreator(Document):
self,
)
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)
amount += flt(row.amount)
@@ -356,7 +358,6 @@ class BOMCreator(Document):
{
"bom_no": bom_no,
"allow_alternative_item": 1,
"allow_scrap_items": not item.get("is_phantom_item"),
"include_item_in_manufacturing": 1,
}
)

View File

@@ -55,7 +55,6 @@
},
{
"columns": 2,
"depends_on": "eval:!doc.workstation_type",
"fieldname": "workstation",
"fieldtype": "Link",
"in_list_view": 1,
@@ -297,7 +296,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-02-17 15:33:28.495850",
"modified": "2026-03-31 17:09:48.771834",
"modified_by": "Administrator",
"module": "Manufacturing",
"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
# import frappe
from frappe.model.document import Document
class BOMScrapItem(Document):
class BOMSecondaryItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -14,17 +14,26 @@ class BOMScrapItem(Document):
if TYPE_CHECKING:
from frappe.types import DF
amount: DF.Currency
base_amount: DF.Currency
base_rate: DF.Currency
base_cost: DF.Currency
conversion_factor: DF.Float
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_name: DF.Data | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
process_loss_per: DF.Percent
process_loss_qty: DF.Float
qty: DF.Float
rate: DF.Currency
stock_qty: DF.Float
stock_uom: DF.Link | None
type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
uom: DF.Link
# end: auto-generated types
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 {
filters: {
disabled: 0,
@@ -104,7 +104,7 @@ frappe.ui.form.on("Job Card", {
frm.doc.docstatus === 1 &&
!frm.doc.is_subcontracted &&
(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"), () => {
frappe.confirm(
@@ -278,8 +278,6 @@ frappe.ui.form.on("Job Card", {
frm.trigger("complete_job_card");
});
}
frm.trigger("make_dashboard");
}
}

View File

@@ -59,8 +59,8 @@
"time_logs",
"section_break_21",
"sub_operations",
"scrap_items_section",
"scrap_items",
"secondary_items_section",
"secondary_items",
"corrective_operation_section",
"for_job_card",
"is_corrective_job_card",
@@ -406,20 +406,6 @@
"options": "Batch",
"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",
"fieldname": "quality_inspection_template",
@@ -623,12 +609,26 @@
{
"fieldname": "column_break_xhzg",
"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,
"is_submittable": 1,
"links": [],
"modified": "2026-02-06 18:27:03.178783",
"modified": "2026-02-26 15:13:56.767070",
"modified_by": "Administrator",
"module": "Manufacturing",
"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 (
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
actual_end_date: DF.Datetime | None
@@ -110,7 +112,7 @@ class JobCard(Document):
remarks: DF.SmallText | None
requested_qty: DF.Float
scheduled_time_logs: DF.Table[JobCardScheduledTime]
scrap_items: DF.Table[JobCardScrapItem]
secondary_items: DF.Table[JobCardSecondaryItem]
semi_fg_bom: DF.Link | None
sequence_id: DF.Int
serial_and_batch_bundle: DF.Link | None
@@ -199,6 +201,7 @@ class JobCard(Document):
def set_manufactured_qty(self):
table_name = "Stock Entry"
child_name = "Stock Entry Detail"
if self.is_subcontracted:
table_name = "Subcontracting Receipt Item"
@@ -208,8 +211,13 @@ class JobCard(Document):
if self.is_subcontracted:
query = query.select(Sum(table.qty))
else:
query = query.select(Sum(table.fg_completed_qty))
query = query.where(table.purpose == "Manufacture")
child = frappe.qb.DocType(child_name)
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
self.manufactured_qty = flt(qty)
@@ -267,25 +275,35 @@ class JobCard(Document):
row.sub_operation = row.operation
self.append("sub_operations", row)
def set_scrap_items(self):
if not self.semi_fg_bom:
def set_secondary_items(self):
if not self.semi_fg_bom and not self.bom_no:
return
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():
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(
"scrap_items",
{
"item_code": item_code,
"stock_qty": values.qty,
"item_name": values.item_name,
"stock_uom": values.stock_uom,
},
)
if not values.is_legacy:
secondary_item["stock_qty"] -= flt(
secondary_item["stock_qty"] * (values.process_loss_per / 100),
self.precision("for_quantity"),
)
self.append("secondary_items", secondary_item)
def validate_time_logs(self, save=False):
self.total_time_in_mins = 0.0
@@ -1181,7 +1199,7 @@ class JobCard(Document):
def set_status(self, update_status=False):
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
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"
elif self.transferred_qty > 0 or self.skip_material_transfer:
self.status = "Work In Progress"
@@ -1456,12 +1474,24 @@ class JobCard(Document):
)
@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
ste = ManufactureEntry(
{
"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,
"skip_material_transfer": self.skip_material_transfer,
"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)
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:
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
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))
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():
"Create a BOM with multiple operations and Material Transfer against Job Card"

View File

@@ -5,10 +5,12 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"type",
"description",
"column_break_3",
"item_code",
"item_name",
"column_break_3",
"description",
"bom_secondary_item",
"quantity_and_rate",
"stock_qty",
"column_break_6",
@@ -19,7 +21,7 @@
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Scrap Item Code",
"label": "Secondary Item Code",
"options": "Item",
"reqd": 1
},
@@ -28,7 +30,7 @@
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Scrap Item Name"
"label": "Secondary Item Name"
},
{
"fieldname": "column_break_3",
@@ -65,20 +67,36 @@
"label": "Stock UOM",
"options": "UOM",
"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,
"istable": 1,
"links": [],
"modified": "2025-07-29 13:09:57.323835",
"modified": "2026-03-06 13:51:00.492621",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Scrap Item",
"name": "Job Card Secondary Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

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

View File

@@ -36,7 +36,7 @@
"capacity_planning_for_days",
"mins_between_operations",
"other_settings_section",
"set_op_cost_and_scrap_from_sub_assemblies",
"set_op_cost_and_secondary_items_from_sub_assemblies",
"column_break_23",
"make_serial_no_batch_from_work_order"
],
@@ -202,13 +202,6 @@
"fieldtype": "Check",
"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",
"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",
"fieldtype": "Check",
"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,
@@ -244,7 +244,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:20.714576",
"modified": "2026-03-20 13:28:20.714576",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",

View File

@@ -32,7 +32,7 @@ class ManufacturingSettings(Document):
mins_between_operations: DF.Int
overproduction_percentage_for_sales_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
update_bom_costs_automatically: 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",
"routing": args.routing,
"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:
bom.insert(ignore_permissions=True)

View File

@@ -6,7 +6,7 @@ from collections import defaultdict
import frappe
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 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)
)
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)
# 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
)
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)
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(
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):
@@ -1015,7 +1015,7 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(wo.status, "Completed")
@timeout(seconds=60)
def test_job_card_scrap_item(self):
def test_job_card_secondary_item(self):
items = [
"Test FG Item 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))
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)
# 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))
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)
# Partial Job Card 2 with qty 10
@@ -2134,10 +2134,12 @@ class TestWorkOrder(ERPNextTestSuite):
for row in se_doc.additional_costs:
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
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 = {
"Test Final FG Item": 0,
@@ -2169,16 +2171,20 @@ class TestWorkOrder(ERPNextTestSuite):
se_doc.save()
self.assertTrue(se_doc.additional_costs)
scrap_items = []
secondary_items = []
for item in se_doc.items:
if item.is_scrap_item:
scrap_items.append(item.item_code)
if item.type or item.is_legacy_scrap_item:
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:
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(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
@@ -2413,7 +2419,7 @@ class TestWorkOrder(ERPNextTestSuite):
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
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
@@ -2453,27 +2459,9 @@ class TestWorkOrder(ERPNextTestSuite):
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_for_manufacture.submit()
# Simulate a disassembly stock entry
# Disassembly via WO required_items path (no source_stock_entry)
disassemble_qty = 4
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()
stock_entry.save()
@@ -2488,7 +2476,7 @@ class TestWorkOrder(ERPNextTestSuite):
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:
if item.item_code == fg_item:
continue
@@ -2512,10 +2500,35 @@ class TestWorkOrder(ERPNextTestSuite):
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):
"""
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:
1. Create Work Order for 10 units
@@ -2524,11 +2537,19 @@ class TestWorkOrder(ERPNextTestSuite):
4. Create Disassembly for 4 units
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_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
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
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}",
)
expected_items = 3 # FG item + 2 raw materials
expected_items = 4 # FG item + 2 raw materials + 1 scrap item
self.assertEqual(
len(stock_entry.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)
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
for bom_item in bom.items:
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",
)
# -- 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):
"""
Test that disassembly correctly handles additional raw materials that were
manually added during manufacturing (not part of the BOM).
Test that SE-linked disassembly includes additional raw materials
that were manually added during manufacturing (not part of the BOM).
Scenario:
1. Create Work Order for 10 units with 2 raw materials in BOM
2. Transfer raw materials for manufacture
3. Manufacture in 2 parts (3 units, then 7 units)
4. In each manufacture entry, manually add an extra consumable item
(not in BOM) in proportion to the manufactured qty
5. Create Disassembly for 4 units
6. Verify that the additional RM is included in disassembly with proportional qty
5. Disassemble 3 units linked to first manufacture entry
6. Verify additional RM is included with correct proportional qty from SE1
"""
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
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.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))
# Additional RM
se_manufacture1.append(
"items",
{
@@ -2688,9 +2757,8 @@ class TestWorkOrder(ERPNextTestSuite):
se_manufacture1.save()
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))
# AAdditional RM
se_manufacture2.append(
"items",
{
@@ -2706,13 +2774,15 @@ class TestWorkOrder(ERPNextTestSuite):
wo.reload()
self.assertEqual(wo.produced_qty, 10)
# Disassembly for 4 units
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
# Disassemble 3 units linked to first manufacture entry
disassemble_qty = 3
stock_entry = frappe.get_doc(
make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name)
)
stock_entry.save()
stock_entry.submit()
# No duplicate
# No duplicates
item_counts = {}
for item in stock_entry.items:
item_code = item.item_code
@@ -2725,16 +2795,15 @@ class TestWorkOrder(ERPNextTestSuite):
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)
self.assertIsNotNone(
additional_rm_row,
f"Additional raw material {additional_rm} not found in disassembly",
)
# intentional full reversal as not part of BOM
# eg: dies or consumables used during manufacturing
expected_additional_rm_qty = 3 + 7
# SE1 had 3 additional RM for 3 manufactured units, disassembling all 3
expected_additional_rm_qty = 3
self.assertAlmostEqual(
additional_rm_row.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}",
)
# RM qty
# BOM RM qty — scaled from SE1's rows
for bom_item in bom.items:
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)
@@ -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)
self.assertEqual(fg_item_row.qty, disassemble_qty)
# FG + 2 BOM RM + 1 additional RM = 4 items
expected_items = 4
self.assertEqual(
len(stock_entry.items),
@@ -2765,6 +2835,282 @@ class TestWorkOrder(ERPNextTestSuite):
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):
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)
@@ -3951,7 +4297,7 @@ def prepare_boms_for_sub_assembly_test():
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()
@@ -3964,7 +4310,7 @@ def prepare_boms_for_sub_assembly_test():
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()
@@ -4159,7 +4505,7 @@ def update_job_card(job_card, jc_qty=None, days=None):
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
job_card_doc = frappe.get_doc("Job Card", job_card)
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 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()
def get_scrap_item_details(bom_no):
scrap_items = {}
def get_secondary_item_details(bom_no):
secondary_items = {}
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""",
bom_no,
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):

View File

@@ -244,13 +244,16 @@ frappe.ui.form.on("Work Order", {
},
toggle_items_editable(frm) {
if (!frm.doc.__onload?.allow_editing_items) {
frm.set_df_property("required_items", "cannot_delete_rows", true);
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.fields_dict["required_items"].grid.update_docfield_property("required_qty", "read_only", 1);
frm.fields_dict["required_items"].grid.refresh();
}
let allow_edit = true;
if (!frm.doc.__onload?.allow_editing_items) allow_edit = false;
frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit);
frm.set_df_property("required_items", "cannot_add_rows", !allow_edit);
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) {
@@ -387,6 +390,7 @@ frappe.ui.form.on("Work Order", {
args: {
work_order: frm.doc.name,
operations: selected_rows,
parent_bom: frm.doc.bom_no,
},
callback: function () {
frm.reload_doc();
@@ -437,7 +441,7 @@ frappe.ui.form.on("Work Order", {
make_disassembly_order(frm) {
erpnext.work_order
.show_prompt_for_qty_input(frm, "Disassemble")
.show_disassembly_prompt(frm)
.then((data) => {
if (flt(data.qty) <= 0) {
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,
purpose: "Disassemble",
qty: data.qty,
source_stock_entry: data.source_stock_entry,
});
})
.then((stock_entry) => {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
if (stock_entry) {
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"));
},
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) {
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:
bom = frappe.get_doc("BOM", bom_no)
if len(bom.scrap_items) > 0:
if bom.has_scrap_items():
res["set_scrap_wh_mandatory"] = True
return res
@@ -2376,6 +2376,7 @@ def make_stock_entry(
qty: float | None = None,
target_warehouse: str | None = None,
is_additional_transfer_entry: bool = False,
source_stock_entry: str | None = None,
):
work_order = frappe.get_doc("Work Order", work_order_id)
if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
@@ -2416,10 +2417,13 @@ def make_stock_entry(
if purpose == "Disassemble":
stock_entry.from_warehouse = work_order.fg_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.is_additional_transfer_entry = is_additional_transfer_entry
stock_entry.get_items()
stock_entry.set_secondary_items_from_job_card()
if purpose != "Disassemble":
stock_entry.set_serial_no_batch_for_finished_good()
@@ -2427,6 +2431,26 @@ def make_stock_entry(
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()
def get_default_warehouse(company):
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()
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):
operations = json.loads(operations)
work_order = frappe.get_doc("Work Order", work_order)
for row in operations:
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)
qty = row.get("qty")
@@ -2495,7 +2519,7 @@ def make_job_card(work_order, operations):
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:
if row.name == name:
return {
@@ -2505,7 +2529,7 @@ def get_operation_details(name, work_order):
"fg_warehouse": row.fg_warehouse,
"wip_warehouse": row.wip_warehouse,
"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"),
}
@@ -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
):
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:
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.update_requested_qty_packed_item
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) {
let html = `
<a class="text-white mb-2 inline-block cursor-pointer"
href="/app/task/${ganttobj.id}"">
<a class="mb-2 inline-block cursor-pointer"
href="/app/task/${ganttobj.id}">
${ganttobj.name}
</a>
`;
if (task.project) {
html += `<p class="mb-1">${__("Project")}:
<a class="text-white inline-block"
href="/app/project/${task.project}"">
<a class="inline-block"
href="/app/project/${task.project}">
${task.project}
</a>
</p>`;
}
html += `<p class="mb-1">
${__("Progress")}:
<span class="text-white">${ganttobj.progress}%</span>
<span>${ganttobj.progress}%</span>
</p>`;
if (task._assign) {
const assign_list = JSON.parse(task._assign);
const assignment_wrapper = `
<span>Assigned to:</span>
<span class="text-white">
<span>
${assign_list.map((user) => frappe.user_info(user).fullname).join(", ")}
</span>
`;

View File

@@ -459,8 +459,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
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();
return;
}
@@ -1851,7 +1855,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"base_operating_cost",
"base_raw_material_cost",
"base_total_cost",
"base_scrap_material_cost",
"base_secondary_items_cost",
"base_totals_section",
],
company_currency
@@ -1869,7 +1873,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"paid_amount",
"write_off_amount",
"operating_cost",
"scrap_material_cost",
"secondary_items_cost",
"raw_material_cost",
"total_cost",
"totals_section",
@@ -1915,7 +1919,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"base_operating_cost",
"base_raw_material_cost",
"base_total_cost",
"base_scrap_material_cost",
"base_secondary_items_cost",
"base_rounding_adjustment",
],
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) {
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items");
this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items");
if (this.frm.doc.secondary_items && this.frm.doc.secondary_items.length > 0) {
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "secondary_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) {
if (frappe.meta.get_docfield(item_grid.doctype, fname))
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) {
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"];
if (
@@ -44,7 +48,11 @@ $.extend(erpnext, {
}
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";
@@ -56,6 +64,12 @@ $.extend(erpnext, {
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) => {
if (frm.fields_dict[child_name].get_field(field)) {
frm.fields_dict[child_name].grid.update_docfield_property(field, "hidden", hide_fields);
@@ -68,7 +82,11 @@ $.extend(erpnext, {
if (
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(
field,
@@ -81,12 +99,14 @@ $.extend(erpnext, {
"in_list_view",
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();
},

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

View File

@@ -173,6 +173,7 @@ class Customer(TransactionBase):
def validate(self):
self.flags.is_new_doc = self.is_new()
self.flags.old_lead = self.lead_name
self.validate_customer_group()
validate_party_accounts(self)
self.validate_credit_limit_on_change()
self.set_loyalty_program()
@@ -356,6 +357,17 @@ class Customer(TransactionBase):
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):
if self.get("__islocal") or not self.credit_limits:
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) {
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.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.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
make_maintenance_schedule,
@@ -35,10 +34,7 @@ from erpnext.stock.get_item_details import get_bin_details
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
def setUp(self):
self.create_customer("_Test Customer Credit")
class TestSalesOrder(ERPNextTestSuite):
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
@@ -2439,7 +2435,7 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
def test_credit_limit_on_so_reopning(self):
# set credit limit
company = "_Test Company"
customer = frappe.get_doc("Customer", self.customer)
customer = frappe.get_doc("Customer", "_Test Customer")
customer.credit_limits = []
customer.append(
"credit_limits", {"company": company, "credit_limit": 1000, "bypass_credit_limit_check": False}
@@ -2447,35 +2443,33 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
customer.save()
so1 = make_sales_order(qty=9, rate=100, do_not_submit=True)
so1.customer = self.customer
so1.customer = customer.name
so1.save().submit()
so1.update_status("Closed")
so2 = make_sales_order(qty=9, rate=100, do_not_submit=True)
so2.customer = self.customer
so2.customer = customer.name
so2.save().submit()
self.assertRaises(frappe.ValidationError, so1.update_status, "Draft")
@ERPNextTestSuite.change_settings("Stock Settings", {"enable_stock_reservation": True})
def test_warehouse_mapping_based_on_stock_reservation(self):
self.create_company(company_name="Glass Ceiling", abbr="GC")
self.create_item("Lamy Safari 2", True, self.warehouse_stores, self.company, 2000)
self.create_customer()
self.clear_old_entries()
warehouse = "Stores - _TC"
warehouse_finished = "Finished Goods - _TC"
so = frappe.new_doc("Sales Order")
so.company = self.company
so.customer = self.customer
so.company = "_Test Company"
so.customer = "_Test Customer"
so.transaction_date = today()
so.append(
"items",
{
"item_code": self.item,
"item_code": "_Test Item",
"qty": 10,
"rate": 2000,
"warehouse": self.warehouse_stores,
"warehouse": "Stores - _TC",
"delivery_date": today(),
},
)
@@ -2485,12 +2479,12 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
se = frappe.get_doc(
{
"doctype": "Stock Entry",
"company": self.company,
"company": "_Test Company",
"stock_entry_type": "Material Receipt",
"posting_date": today(),
"items": [
{"item_code": self.item, "t_warehouse": self.warehouse_stores, "qty": 5},
{"item_code": self.item, "t_warehouse": self.warehouse_finished_goods, "qty": 5},
{"item_code": "_Test Item", "t_warehouse": warehouse, "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,
"item_code": itm.item_code,
"warehouse": self.warehouse_stores,
"warehouse": warehouse,
"qty_to_reserve": 2,
}
]
@@ -2513,7 +2507,7 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
{
"sales_order_item": itm.name,
"item_code": itm.item_code,
"warehouse": self.warehouse_finished_goods,
"warehouse": warehouse_finished,
"qty_to_reserve": 3,
}
]
@@ -2523,31 +2517,31 @@ class TestSalesOrder(AccountsTestMixin, ERPNextTestSuite):
dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True})
self.assertEqual(2, len(dn.items))
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].warehouse, self.warehouse_finished_goods)
self.assertEqual(dn.items[1].warehouse, warehouse_finished)
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(
item_code=self.item,
item_code="_Test Item",
target=warehouse,
qty=5,
company=self.company,
company="_Test Company",
)
so = frappe.new_doc("Sales Order")
so.reserve_stock = 1
so.company = self.company
so.customer = self.customer
so.company = "_Test Company"
so.customer = "_Test Customer"
so.transaction_date = today()
so.currency = "INR"
so.append(
"items",
{
"item_code": self.item,
"item_code": "_Test Item",
"qty": 5,
"rate": 2000,
"warehouse": warehouse,

View File

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

View File

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

View File

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

View File

@@ -820,7 +820,7 @@ class Company(NestedSet):
boms = frappe.db.sql_list("select name from tabBOM where company=%s", self.name)
if boms:
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(
"delete from `tab{}` where parent in ({})".format(dt, ", ".join(["%s"] * len(boms))),
tuple(boms),

View File

@@ -301,7 +301,7 @@ class Employee(NestedSet):
frappe.throw(_("User {0} does not exist").format(self.user_id))
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):
Employee = frappe.qb.DocType("Employee")

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