mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-09 01:45:09 +00:00
Merge pull request #54100 from frappe/version-16-hotfix
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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> # filters: dict — report filters (company, period, etc.) <br> # periods: list[dict] — period definitions <br> # row: dict — the current report row <br><br> 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 = `
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
104
erpnext/patches/v16_0/co_by_product_patch.py
Normal file
104
erpnext/patches/v16_0/co_by_product_patch.py
Normal 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")
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
4
erpnext/regional/address_template/templates/croatia.html
Normal file
4
erpnext/regional/address_template/templates/croatia.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
|
||||
{{ pincode }} {{ city | upper }}<br>
|
||||
{{ country | upper }}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user