diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py index 38f3a91c8fe..52fe3cd148c 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py @@ -33,6 +33,12 @@ class FiscalYear(Document): self.validate_dates() self.validate_overlap() + def on_update(self): + frappe.cache().delete_key("fiscal_years") + + def on_trash(self): + frappe.cache().delete_key("fiscal_years") + def validate_dates(self): self.validate_from_to_dates("year_start_date", "year_end_date") if self.is_short_year: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index bfa28630d6f..585ec41ffac 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1092,20 +1092,32 @@ class PaymentEntry(AccountsController): self.base_paid_amount + deductions_to_consider ): self.unallocated_amount = ( - self.base_paid_amount - + deductions_to_consider - - self.base_total_allocated_amount - - included_taxes - ) / self.source_exchange_rate + flt( + ( + self.base_paid_amount + + deductions_to_consider + - self.base_total_allocated_amount + - included_taxes + ), + self.precision("unallocated_amount"), + ) + / self.source_exchange_rate + ) elif self.payment_type == "Pay" and self.base_total_allocated_amount < ( self.base_received_amount - deductions_to_consider ): self.unallocated_amount = ( - self.base_received_amount - - deductions_to_consider - - self.base_total_allocated_amount - - included_taxes - ) / self.target_exchange_rate + flt( + ( + self.base_received_amount + - deductions_to_consider + - self.base_total_allocated_amount + - included_taxes + ), + self.precision("unallocated_amount"), + ) + / self.target_exchange_rate + ) def set_exchange_gain_loss(self): exchange_gain_loss = flt( diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 14ec325bea2..cc090df9270 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -346,8 +346,7 @@ def apply_pricing_rule(args, doc=None): args = frappe._dict(args) - if not args.transaction_type: - set_transaction_type(args) + set_transaction_type(args) # list of dictionaries out = [] @@ -683,23 +682,23 @@ def remove_pricing_rules(item_list): return out -def set_transaction_type(args): - if args.transaction_type: +def set_transaction_type(pricing_ctx: frappe._dict) -> None: + if pricing_ctx.transaction_type in ["buying", "selling"]: return - if args.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"): - args.transaction_type = "selling" - elif args.doctype in ( + if pricing_ctx.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"): + pricing_ctx.transaction_type = "selling" + elif pricing_ctx.doctype in ( "Material Request", "Supplier Quotation", "Purchase Order", "Purchase Receipt", "Purchase Invoice", ): - args.transaction_type = "buying" - elif args.customer: - args.transaction_type = "selling" + pricing_ctx.transaction_type = "buying" + elif pricing_ctx.customer: + pricing_ctx.transaction_type = "selling" else: - args.transaction_type = "buying" + pricing_ctx.transaction_type = "buying" @frappe.whitelist() diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py index 8971dc3d37b..0c104f6f96e 100644 --- a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py +++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py @@ -1,6 +1,6 @@ import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import today +from frappe.utils import add_days, today from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.report.accounts_payable.accounts_payable import execute @@ -57,3 +57,66 @@ class TestAccountsPayable(AccountsTestMixin, FrappeTestCase): if not do_not_submit: pi = pi.submit() return pi + + def test_payment_terms_template_filters(self): + from erpnext.controllers.accounts_controller import get_payment_terms + + payment_term1 = frappe.get_doc( + {"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"} + ).insert() + payment_term2 = frappe.get_doc( + {"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"} + ).insert() + + template = frappe.get_doc( + { + "doctype": "Payment Terms Template", + "template_name": "_Test 50-50", + "terms": [ + { + "doctype": "Payment Terms Template Detail", + "due_date_based_on": "Day(s) after invoice date", + "payment_term": payment_term1.name, + "description": "_Test 50-50", + "invoice_portion": 50, + "credit_days": 15, + }, + { + "doctype": "Payment Terms Template Detail", + "due_date_based_on": "Day(s) after invoice date", + "payment_term": payment_term2.name, + "description": "_Test 50-50", + "invoice_portion": 50, + "credit_days": 30, + }, + ], + } + ) + template.insert() + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "based_on_payment_terms": 1, + "payment_terms_template": template.name, + "ageing_based_on": "Posting Date", + } + + pi = self.create_purchase_invoice(do_not_submit=True) + pi.payment_terms_template = template.name + schedule = get_payment_terms(template.name) + pi.set("payment_schedule", []) + + for row in schedule: + row["due_date"] = add_days(pi.posting_date, row.get("credit_days", 0)) + pi.append("payment_schedule", row) + + pi.save() + pi.submit() + + report = execute(filters) + row = report[1][0] + + self.assertEqual(len(report[1]), 2) + self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 9ca930bd829..831873055f1 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -1029,9 +1029,8 @@ class ReceivablePayableReport: self, ): self.customer = qb.DocType("Customer") - if self.filters.get("customer_group"): - groups = get_customer_group_with_children(self.filters.customer_group) + groups = get_party_group_with_children("Customer", self.filters.customer_group) customers = ( qb.from_(self.customer) .select(self.customer.name) @@ -1043,14 +1042,18 @@ class ReceivablePayableReport: self.get_hierarchical_filters("Territory", "territory") if self.filters.get("payment_terms_template"): - self.qb_selection_filter.append( - self.ple.party.isin( - qb.from_(self.customer) - .select(self.customer.name) - .where(self.customer.payment_terms == self.filters.get("payment_terms_template")) - ) + customer_ptt = self.ple.party.isin( + qb.from_(self.customer) + .select(self.customer.name) + .where(self.customer.payment_terms == self.filters.get("payment_terms_template")) ) + si_ptt = self.add_payment_term_template_filters("Sales Invoice") + + sales_ptt = self.ple.against_voucher_no.isin(si_ptt) + + self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt])) + if self.filters.get("sales_partner"): self.qb_selection_filter.append( self.ple.party.isin( @@ -1075,14 +1078,53 @@ class ReceivablePayableReport: ) if self.filters.get("payment_terms_template"): - self.qb_selection_filter.append( - self.ple.party.isin( - qb.from_(supplier) - .select(supplier.name) - .where(supplier.payment_terms == self.filters.get("supplier_group")) - ) + supplier_ptt = self.ple.party.isin( + qb.from_(supplier) + .select(supplier.name) + .where(supplier.payment_terms == self.filters.get("payment_terms_template")) ) + pi_ptt = self.add_payment_term_template_filters("Purchase Invoice") + + purchase_ptt = self.ple.against_voucher_no.isin(pi_ptt) + + self.qb_selection_filter.append(Criterion.any([supplier_ptt, purchase_ptt])) + + def add_payment_term_template_filters(self, dtype): + voucher_type = qb.DocType(dtype) + + ptt = ( + qb.from_(voucher_type) + .select(voucher_type.name) + .where(voucher_type.payment_terms_template == self.filters.get("payment_terms_template")) + .where(voucher_type.company == self.filters.company) + ) + + if dtype == "Purchase Invoice": + party = "Supplier" + party_group_type = "supplier_group" + acc_type = "credit_to" + else: + party = "Customer" + party_group_type = "customer_group" + acc_type = "debit_to" + + if self.filters.get(party_group_type): + party_groups = get_party_group_with_children(party, self.filters.get(party_group_type)) + ptt = ptt.where((voucher_type[party_group_type]).isin(party_groups)) + + if self.filters.party: + ptt = ptt.where((voucher_type[party.lower()]).isin(self.filters.party)) + + if self.filters.cost_center: + cost_centers = get_cost_centers_with_children(self.filters.cost_center) + ptt = ptt.where(voucher_type.cost_center.isin(cost_centers)) + + if self.filters.party_account: + ptt = ptt.where(voucher_type[acc_type] == self.filters.party_account) + + return ptt + def get_hierarchical_filters(self, doctype, key): lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"]) @@ -1320,20 +1362,26 @@ class ReceivablePayableReport: self.err_journals = [x[0] for x in results] if results else [] -def get_customer_group_with_children(customer_groups): - if not isinstance(customer_groups, list): - customer_groups = [d.strip() for d in customer_groups.strip().split(",") if d] +def get_party_group_with_children(party, party_groups): + if party not in ("Customer", "Supplier"): + return [] - all_customer_groups = [] - for d in customer_groups: - if frappe.db.exists("Customer Group", d): - lft, rgt = frappe.db.get_value("Customer Group", d, ["lft", "rgt"]) - children = frappe.get_all("Customer Group", filters={"lft": [">=", lft], "rgt": ["<=", rgt]}) - all_customer_groups += [c.name for c in children] + group_dtype = f"{party} Group" + if not isinstance(party_groups, list): + party_groups = [d.strip() for d in party_groups.strip().split(",") if d] + + all_party_groups = [] + for d in party_groups: + if frappe.db.exists(group_dtype, d): + lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"]) + children = frappe.get_all( + group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name" + ) + all_party_groups += children else: - frappe.throw(_("Customer Group: {0} does not exist").format(d)) + frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d)) - return list(set(all_customer_groups)) + return list(set(all_party_groups)) class InitSQLProceduresForAR: diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 19f51dc7a03..4dfcd3bf259 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -1139,3 +1139,66 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(report[1]), 1) row = report[1][0] self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding]) + + def test_payment_terms_template_filters(self): + from erpnext.controllers.accounts_controller import get_payment_terms + + payment_term1 = frappe.get_doc( + {"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"} + ).insert() + payment_term2 = frappe.get_doc( + {"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"} + ).insert() + + template = frappe.get_doc( + { + "doctype": "Payment Terms Template", + "template_name": "_Test 50-50", + "terms": [ + { + "doctype": "Payment Terms Template Detail", + "due_date_based_on": "Day(s) after invoice date", + "payment_term": payment_term1.name, + "description": "_Test 50-50", + "invoice_portion": 50, + "credit_days": 15, + }, + { + "doctype": "Payment Terms Template Detail", + "due_date_based_on": "Day(s) after invoice date", + "payment_term": payment_term2.name, + "description": "_Test 50-50", + "invoice_portion": 50, + "credit_days": 30, + }, + ], + } + ) + template.insert() + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "based_on_payment_terms": 1, + "payment_terms_template": template.name, + "ageing_based_on": "Posting Date", + } + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.payment_terms_template = template.name + schedule = get_payment_terms(template.name) + si.set("payment_schedule", []) + + for row in schedule: + row["due_date"] = add_days(si.posting_date, row.get("credit_days", 0)) + si.append("payment_schedule", row) + + si.save() + si.submit() + + report = execute(filters) + row = report[1][0] + + self.assertEqual(len(report[1]), 2) + self.assertEqual([si.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index fc19c40f8f9..e2dc2f01943 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -96,7 +96,7 @@ def execute(filters=None): filters.periodicity, period_list, filters.accumulated_values, company=filters.company ) - chart = get_chart_data(filters, columns, asset, liability, equity, currency) + chart = get_chart_data(filters, period_list, asset, liability, equity, currency) report_summary, primitive_summary = get_report_summary( period_list, asset, liability, equity, provisional_profit_loss, currency, filters @@ -225,18 +225,19 @@ def get_report_summary( ], (net_asset - net_liability + net_equity) -def get_chart_data(filters, columns, asset, liability, equity, currency): - labels = [d.get("label") for d in columns[2:]] +def get_chart_data(filters, chart_columns, asset, liability, equity, currency): + labels = [col.get("label") for col in chart_columns] asset_data, liability_data, equity_data = [], [], [] - for p in columns[2:]: + for col in chart_columns: + key = col.get("key") or col.get("fieldname") if asset: - asset_data.append(asset[-2].get(p.get("fieldname"))) + asset_data.append(asset[-2].get(key)) if liability: - liability_data.append(liability[-2].get(p.get("fieldname"))) + liability_data.append(liability[-2].get(key)) if equity: - equity_data.append(equity[-2].get(p.get("fieldname"))) + equity_data.append(equity[-2].get(key)) datasets = [] if asset_data: diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index 0ee0cb14edb..c4b894a34b8 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -139,7 +139,7 @@ def execute(filters=None): True, ) - chart = get_chart_data(columns, data, company_currency) + chart = get_chart_data(period_list, data, company_currency) report_summary = get_report_summary(summary_data, company_currency) @@ -411,13 +411,12 @@ def get_report_summary(summary_data, currency): return report_summary -def get_chart_data(columns, data, currency): - labels = [d.get("label") for d in columns[2:]] - print(data) +def get_chart_data(period_list, data, currency): + labels = [period.get("label") for period in period_list] datasets = [ { "name": section.get("section").replace("'", ""), - "values": [section.get(d.get("fieldname")) for d in columns[2:]], + "values": [section.get(period.get("key")) for period in period_list], } for section in data if section.get("parent_section") is None and section.get("currency") diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 040ac6008c0..fbbe144707d 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -48,22 +48,25 @@ def execute(filters=None): return columns, data, message, chart fiscal_year = get_fiscal_year_data(filters.get("from_fiscal_year"), filters.get("to_fiscal_year")) - companies_column, companies = get_companies(filters) - columns = get_columns(companies_column, filters) + company_list, companies = get_companies(filters) + company_columns = get_company_columns(company_list, filters) + columns = get_columns(company_columns) if filters.get("report") == "Balance Sheet": data, message, chart, report_summary = get_balance_sheet_data( - fiscal_year, companies, columns, filters + fiscal_year, companies, company_columns, filters ) elif filters.get("report") == "Profit and Loss Statement": - data, message, chart, report_summary = get_profit_loss_data(fiscal_year, companies, columns, filters) + data, message, chart, report_summary = get_profit_loss_data( + fiscal_year, companies, company_columns, filters + ) else: data, report_summary = get_cash_flow_data(fiscal_year, companies, filters) return columns, data, message, chart, report_summary -def get_balance_sheet_data(fiscal_year, companies, columns, filters): +def get_balance_sheet_data(fiscal_year, companies, company_columns, filters): asset = get_data(companies, "Asset", "Debit", fiscal_year, filters=filters) liability = get_data(companies, "Liability", "Credit", fiscal_year, filters=filters) @@ -116,7 +119,7 @@ def get_balance_sheet_data(fiscal_year, companies, columns, filters): True, ) - chart = get_chart_data(filters, columns, asset, liability, equity, company_currency) + chart = get_chart_data(filters, company_columns, asset, liability, equity, company_currency) return data, message, chart, report_summary @@ -164,7 +167,7 @@ def get_root_account_name(root_type, company): return root_account[0][0] -def get_profit_loss_data(fiscal_year, companies, columns, filters): +def get_profit_loss_data(fiscal_year, companies, company_columns, filters): income, expense, net_profit_loss = get_income_expense_data(companies, fiscal_year, filters) company_currency = get_company_currency(filters) @@ -174,7 +177,7 @@ def get_profit_loss_data(fiscal_year, companies, columns, filters): if net_profit_loss: data.append(net_profit_loss) - chart = get_pl_chart_data(filters, columns, income, expense, net_profit_loss, company_currency) + chart = get_pl_chart_data(filters, company_columns, income, expense, net_profit_loss, company_currency) report_summary, primitive_summary = get_pl_summary( companies, "", income, expense, net_profit_loss, company_currency, filters, True @@ -279,7 +282,30 @@ def get_account_type_based_data(account_type, companies, fiscal_year, filters): return data -def get_columns(companies, filters): +def get_company_columns(companies, filters): + company_columns = [] + for company in companies: + apply_currency_formatter = 1 if not filters.presentation_currency else 0 + currency = filters.presentation_currency + if not currency: + currency = erpnext.get_company_currency(company) + + company_columns.append( + { + "fieldname": company, + "label": f"{company} ({currency})", + "fieldtype": "Currency", + "options": "currency", + "width": 150, + "apply_currency_formatter": apply_currency_formatter, + "company_name": company, + } + ) + + return company_columns + + +def get_columns(company_columns): columns = [ { "fieldname": "account", @@ -297,23 +323,7 @@ def get_columns(companies, filters): }, ] - for company in companies: - apply_currency_formatter = 1 if not filters.presentation_currency else 0 - currency = filters.presentation_currency - if not currency: - currency = erpnext.get_company_currency(company) - - columns.append( - { - "fieldname": company, - "label": f"{company} ({currency})", - "fieldtype": "Currency", - "options": "currency", - "width": 150, - "apply_currency_formatter": apply_currency_formatter, - "company_name": company, - } - ) + columns.extend(company_columns) return columns diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index ccb4d26f77b..3e2cc34fc68 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -62,7 +62,7 @@ def execute(filters=None): currency = filters.presentation_currency or frappe.get_cached_value( "Company", filters.company, "default_currency" ) - chart = get_chart_data(filters, columns, income, expense, net_profit_loss, currency) + chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency) report_summary, primitive_summary = get_report_summary( period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters @@ -158,18 +158,20 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co return net_profit_loss -def get_chart_data(filters, columns, income, expense, net_profit_loss, currency): - labels = [d.get("label") for d in columns[2:]] +def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, currency): + labels = [col.get("label") for col in chart_columns] income_data, expense_data, net_profit = [], [], [] - for p in columns[2:]: + for col in chart_columns: + key = col.get("key") or col.get("fieldname") + if income: - income_data.append(income[-2].get(p.get("fieldname"))) + income_data.append(income[-2].get(key)) if expense: - expense_data.append(expense[-2].get(p.get("fieldname"))) + expense_data.append(expense[-2].get(key)) if net_profit_loss: - net_profit.append(net_profit_loss.get(p.get("fieldname"))) + net_profit.append(net_profit_loss.get(key)) datasets = [] if income_data: diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 5f78aef582b..536cbf610c6 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -345,7 +345,7 @@ def calculate_values(accounts, gl_entries_by_account, opening_balances, show_net prepare_opening_closing(d) -def calculate_total_row(accounts, company_currency): +def calculate_total_row(data, company_currency, show_group_accounts=True): total_row = { "account": "'" + _("Total") + "'", "account_name": "'" + _("Total") + "'", @@ -362,10 +362,16 @@ def calculate_total_row(accounts, company_currency): "currency": company_currency, } - for d in accounts: - if not d.parent_account: - for field in value_fields: - total_row[field] += d[field] + def sum_value_fields(row): + for field in value_fields: + total_row[field] += row[field] + + for d in data: + if not show_group_accounts: + sum_value_fields(d) + + elif show_group_accounts and not d.get("parent_account"): + sum_value_fields(d) return total_row @@ -409,11 +415,13 @@ def prepare_data(accounts, filters, parent_children_map, company_currency): row["has_value"] = has_value data.append(row) - total_row = calculate_total_row(accounts, company_currency) - if not filters.get("show_group_accounts"): data = hide_group_accounts(data) + total_row = calculate_total_row( + data, company_currency, show_group_accounts=filters.get("show_group_accounts") + ) + data.extend([{}, total_row]) return data diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 6420f034df7..d9d4d7ea1cc 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -250,10 +250,17 @@ frappe.ui.form.on("Request for Quotation", { "subject", ]) .then((r) => { - frm.set_value( - "message_for_supplier", - r.message.use_html ? r.message.response_html : r.message.response - ); + if (r.message.use_html) { + frm.set_value({ + mfs_html: r.message.response_html, + use_html: 1, + }); + } else { + frm.set_value({ + message_for_supplier: r.message.response, + use_html: 0, + }); + } frm.set_value("subject", r.message.subject); }); } diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index c5e72fb8e20..448deceaef7 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -31,7 +31,9 @@ "send_document_print", "sec_break_email_2", "subject", + "use_html", "message_for_supplier", + "mfs_html", "terms_section_break", "incoterm", "named_place", @@ -142,12 +144,13 @@ { "allow_on_submit": 1, "default": "Please supply the specified items at the best possible rates", + "depends_on": "eval:doc.use_html == 0", "fieldname": "message_for_supplier", "fieldtype": "Text Editor", "in_list_view": 1, "label": "Message for Supplier", - "print_hide": 1, - "reqd": 1 + "mandatory_depends_on": "eval:doc.use_html == 0", + "print_hide": 1 }, { "collapsible": 1, @@ -324,6 +327,22 @@ "label": "Subject", "not_nullable": 1, "reqd": 1 + }, + { + "allow_on_submit": 1, + "depends_on": "eval:doc.use_html == 1", + "fieldname": "mfs_html", + "fieldtype": "Code", + "label": "Message for Supplier", + "mandatory_depends_on": "eval:doc.use_html == 1", + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "use_html", + "fieldtype": "Check", + "hidden": 1, + "label": "Use HTML" } ], "grid_page_length": 50, @@ -331,7 +350,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-01-05 14:27:33.329810", + "modified": "2026-03-01 23:38:48.079274", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index cd22aec6960..e14a0265119 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -47,7 +47,8 @@ class RequestforQuotation(BuyingController): incoterm: DF.Link | None items: DF.Table[RequestforQuotationItem] letter_head: DF.Link | None - message_for_supplier: DF.TextEditor + message_for_supplier: DF.TextEditor | None + mfs_html: DF.Code | None named_place: DF.Data | None naming_series: DF.Literal["PUR-RFQ-.YYYY.-"] opportunity: DF.Link | None @@ -61,6 +62,7 @@ class RequestforQuotation(BuyingController): tc_name: DF.Link | None terms: DF.TextEditor | None transaction_date: DF.Date + use_html: DF.Check vendor: DF.Link | None # end: auto-generated types @@ -100,8 +102,16 @@ class RequestforQuotation(BuyingController): ["use_html", "response", "response_html", "subject"], as_dict=True, ) - if not self.message_for_supplier: - self.message_for_supplier = data.response_html if data.use_html else data.response + + self.use_html = data.use_html + + if data.use_html: + if not self.mfs_html: + self.mfs_html = data.response_html + else: + if not self.message_for_supplier: + self.message_for_supplier = data.response + if not self.subject: self.subject = data.subject @@ -304,7 +314,10 @@ class RequestforQuotation(BuyingController): else: sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None - rendered_message = frappe.render_template(self.message_for_supplier, doc_args) + message_template = self.mfs_html if self.use_html else self.message_for_supplier + # nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti + rendered_message = frappe.render_template(message_template, doc_args) + subject_source = ( self.subject or frappe.get_value("Email Template", self.email_template, "subject") diff --git a/erpnext/buying/report/procurement_tracker/procurement_tracker.py b/erpnext/buying/report/procurement_tracker/procurement_tracker.py index 6f610d2dc20..10169c554fb 100644 --- a/erpnext/buying/report/procurement_tracker/procurement_tracker.py +++ b/erpnext/buying/report/procurement_tracker/procurement_tracker.py @@ -165,7 +165,7 @@ def get_data(filters): "cost_center": po.cost_center, "project": po.project, "requesting_site": po.warehouse, - "requestor": po.owner, + "requestor": mr_record.get("owner", po.owner), "material_request_no": po.material_request, "item_code": po.item_code, "quantity": flt(po.qty), diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index a53eb2130a8..f1184851c20 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -936,7 +936,14 @@ def get_serial_batches_based_on_bundle(doctype, field, _bundle_ids): if doctype == "Packed Item": if key is None: - key = frappe.get_cached_value("Packed Item", row.voucher_detail_no, field) + key = frappe.get_cached_value( + "Packed Item", + {"parent_detail_docname": row.voucher_detail_no, "item_code": row.item_code}, + field, + ) + if key is None: + key = frappe.get_cached_value("Packed Item", row.voucher_detail_no, field) + if row.voucher_type == "Delivery Note": key = frappe.get_cached_value("Delivery Note Item", key, "dn_detail") elif row.voucher_type == "Sales Invoice": diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index a8c4a2733fc..acdea69cf22 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -318,9 +318,10 @@ class SellingController(StockController): if is_internal_customer or not is_stock_item: continue - if item.get("incoming_rate") and item.base_net_rate < ( + rate_field = "valuation_rate" if self.doctype in ["Sales Order", "Quotation"] else "incoming_rate" + if item.get(rate_field) and item.base_net_rate < ( valuation_rate := flt( - item.incoming_rate * (item.conversion_factor or 1), item.precision("base_net_rate") + item.get(rate_field) * (item.conversion_factor or 1), item.precision("base_net_rate") ) ): throw_message( diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 3860a7106c4..5d1af9ea394 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -57,6 +57,8 @@ class StockController(AccountsController): if not self.get("is_return"): self.validate_inspection() + + self.validate_warehouse_of_sabb() self.validate_serialized_batch() self.clean_serial_nos() self.validate_customer_provided_item() @@ -65,6 +67,45 @@ class StockController(AccountsController): self.validate_putaway_capacity() self.reset_conversion_factor() + def validate_warehouse_of_sabb(self): + if self.is_internal_transfer(): + return + + doc_before_save = self.get_doc_before_save() + + for row in self.items: + if not row.get("serial_and_batch_bundle"): + continue + + sabb_details = frappe.db.get_value( + "Serial and Batch Bundle", + row.serial_and_batch_bundle, + ["type_of_transaction", "warehouse", "has_serial_no"], + as_dict=True, + ) + if not sabb_details: + continue + + if sabb_details.type_of_transaction != "Outward": + continue + + warehouse = row.get("warehouse") or row.get("s_warehouse") + if sabb_details.warehouse != warehouse: + frappe.throw( + _( + "Row #{0}: Warehouse {1} does not match with the warehouse {2} in Serial and Batch Bundle {3}." + ).format(row.idx, warehouse, sabb_details.warehouse, row.serial_and_batch_bundle) + ) + + if self.doctype == "Stock Reconciliation": + continue + + if sabb_details.has_serial_no and doc_before_save and doc_before_save.get("items"): + prev_row = doc_before_save.get("items", {"idx": row.idx}) + if prev_row and prev_row[0].serial_and_batch_bundle != row.serial_and_batch_bundle: + sabb_doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) + sabb_doc.validate_serial_no_status() + def reset_conversion_factor(self): for row in self.get("items"): if row.uom != row.stock_uom: @@ -1661,7 +1702,7 @@ def check_item_quality_inspection(doctype, items): @frappe.whitelist() -def make_quality_inspections(doctype, docname, items): +def make_quality_inspections(company, doctype, docname, items): if isinstance(items, str): items = json.loads(items) @@ -1680,6 +1721,7 @@ def make_quality_inspections(doctype, docname, items): quality_inspection = frappe.get_doc( { + "company": company, "doctype": "Quality Inspection", "inspection_type": "Incoming", "inspected_by": frappe.session.user, diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js index fcea1cafab9..bb78925b238 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.js +++ b/erpnext/crm/doctype/opportunity/opportunity.js @@ -307,6 +307,21 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller { }; }); + this.frm.set_query("uom", "items", function (doc, cdt, cdn) { + let row = locals[cdt][cdn]; + + if (!row.item_code) { + return; + } + + return { + query: "erpnext.controllers.queries.get_item_uom_query", + filters: { + item_code: row.item_code, + }, + }; + }); + me.frm.set_query("contact_person", erpnext.queries["contact_query"]); if (me.frm.doc.opportunity_from == "Lead") { diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 0bf78429b36..86521586b28 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -59,7 +59,9 @@ def create_prospect_against_crm_deal(): ) pass - create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name) + if doc.contacts and len(doc.contacts): + create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name) + create_address("Prospect", prospect_name, doc.address) frappe.response["message"] = prospect_name diff --git a/erpnext/manufacturing/report/production_analytics/production_analytics.py b/erpnext/manufacturing/report/production_analytics/production_analytics.py index 41fd4dd0e82..9da87022e46 100644 --- a/erpnext/manufacturing/report/production_analytics/production_analytics.py +++ b/erpnext/manufacturing/report/production_analytics/production_analytics.py @@ -6,24 +6,25 @@ import frappe from frappe import _, scrub from frappe.utils import getdate, today -from erpnext.stock.report.stock_analytics.stock_analytics import get_period, get_period_date_ranges +from erpnext.stock.report.stock_analytics.stock_analytics import ( + get_period, + get_period_columns, + get_period_date_ranges, +) WORK_ORDER_STATUS_LIST = ["Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"] def execute(filters=None): - columns = get_columns(filters) - data, chart = get_data(filters, columns) + period_columns = get_period_columns(filters) + columns = get_columns(period_columns) + data, chart = get_data(filters, period_columns) return columns, data, None, chart -def get_columns(filters): +def get_columns(period_columns): columns = [{"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 140}] - ranges = get_period_date_ranges(filters) - - for _dummy, end_date in ranges: - period = get_period(end_date, filters) - columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120}) + columns.extend(period_columns) return columns @@ -49,7 +50,7 @@ def get_work_orders(filters): ) -def get_data(filters, columns): +def get_data(filters, period_columns): ranges = build_ranges(filters) period_labels = [scrub(pd) for _fd, _td, pd in ranges] periodic_data = {status: {pd: 0 for pd in period_labels} for status in WORK_ORDER_STATUS_LIST} @@ -84,7 +85,7 @@ def get_data(filters, columns): row[scrub(period)] = periodic_data[status].get(scrub(period), 0) data.append(row) - chart = get_chart_data(periodic_data, columns) + chart = get_chart_data(periodic_data, period_columns) return data, chart @@ -103,9 +104,9 @@ def build_ranges(filters): return ranges -def get_chart_data(periodic_data, columns): - period_labels = [d.get("label") for d in columns[1:]] - period_fieldnames = [d.get("fieldname") for d in columns[1:]] +def get_chart_data(periodic_data, period_columns): + period_labels = [col.get("label") for col in period_columns] + period_fieldnames = [col.get("fieldname") for col in period_columns] datasets = [] for status in WORK_ORDER_STATUS_LIST: diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 2e782b49e66..ef727eec8d8 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2476,6 +2476,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe frappe.call({ method: "erpnext.controllers.stock_controller.make_quality_inspections", args: { + company: me.frm.doc.company, doctype: me.frm.doc.doctype, docname: me.frm.doc.name, items: selected_data, diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index cff84411231..4fffd1f801e 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -2472,6 +2472,49 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): si2 = make_sales_invoice(so.name) self.assertEqual(si2.items[0].qty, 20) + @change_settings("Selling Settings", {"validate_selling_price": 1}) + def test_selling_price_validation_for_manufactured_item(self): + """ + Unit test to check the selling price validation for manufactured item, without last purchae rate in Item master. + """ + + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + # create a FG Item and RM Item + rm_item = make_item( + "_Test RM Item for SO selling validation", + {"is_stock_item": 1, "valuation_rate": 100, "stock_uom": "Nos"}, + ).name + rm_warehouse = create_warehouse("_Test RM SPV Warehouse") + fg_item = make_item("_Test FG Item for SO selling validation", {"is_stock_item": 1}).name + fg_warehouse = create_warehouse("_Test FG SPV Warehouse") + + # create BOM and inward entry for RM Item + bom_no = make_bom(item=fg_item, raw_materials=[rm_item]).name + make_stock_entry(item_code=rm_item, target=rm_warehouse, qty=10, rate=100) + + # create a manufacture entry, so system won't update the last purchase rate in Item master. + se = make_stock_entry(item_code=fg_item, qty=10, purpose="Manufacture", do_not_save=True) + + se.from_bom = 1 + se.use_multi_level_bom = 1 + se.bom_no = bom_no + se.fg_completed_qty = 1 + se.from_warehouse = rm_warehouse + se.to_warehouse = fg_warehouse + + se.get_items() + se.save() + se.submit() + + # check valuation of FG Item + self.assertEqual(se.items[1].valuation_rate, 100) + + # create a SO for FG Item with selling rate than valuation rate. + so = make_sales_order(item_code=fg_item, qty=10, rate=50, warehouse=fg_warehouse, do_not_save=1) + self.assertRaises(frappe.ValidationError, so.save) + def compare_payment_schedules(doc, doc1, doc2): for index, schedule in enumerate(doc1.get("payment_schedule")): diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 2f98aff00f2..a84f49286ad 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -242,7 +242,7 @@ class Item(Document): 'Image in the description has been removed. To disable this behavior, uncheck "{0}" in {1}.' ).format( frappe.get_meta("Stock Settings").get_label("clean_description_html"), - get_link_to_form("Stock Settings"), + get_link_to_form("Stock Settings", "Stock Settings"), ), alert=True, ) diff --git a/erpnext/stock/doctype/item/item_list.js b/erpnext/stock/doctype/item/item_list.js index 2041d91bc8a..e8d886a9c24 100644 --- a/erpnext/stock/doctype/item/item_list.js +++ b/erpnext/stock/doctype/item/item_list.js @@ -1,5 +1,14 @@ frappe.listview_settings["Item"] = { - add_fields: ["item_name", "stock_uom", "item_group", "image", "has_variants", "end_of_life", "disabled"], + add_fields: [ + "item_name", + "stock_uom", + "item_group", + "image", + "has_variants", + "end_of_life", + "disabled", + "variant_of", + ], filters: [["disabled", "=", "0"]], get_indicator: function (doc) { diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index a70af1fc384..9a478ace4d3 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -14,6 +14,10 @@ from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +class IncorrectCompanyValidationError(frappe.ValidationError): + pass + + class LandedCostVoucher(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -66,6 +70,7 @@ class LandedCostVoucher(Document): self.check_mandatory() self.validate_receipt_documents() self.validate_line_items() + self.validate_expense_accounts() init_landed_taxes_and_totals(self) self.set_total_taxes_and_charges() if not self.get("items"): @@ -101,11 +106,28 @@ class LandedCostVoucher(Document): receipt_documents = [] for d in self.get("purchase_receipts"): - docstatus = frappe.db.get_value(d.receipt_document_type, d.receipt_document, "docstatus") + docstatus, company = frappe.get_cached_value( + d.receipt_document_type, d.receipt_document, ["docstatus", "company"] + ) if docstatus != 1: msg = f"Row {d.idx}: {d.receipt_document_type} {frappe.bold(d.receipt_document)} must be submitted" frappe.throw(_(msg), title=_("Invalid Document")) + if company != self.company: + frappe.throw( + _( + "Row {0}: {1} {2} is linked to company {3}. Please select a document belonging to company {4}." + ).format( + d.idx, + d.receipt_document_type, + frappe.bold(d.receipt_document), + frappe.bold(company), + frappe.bold(self.company), + ), + title=_("Incorrect Company"), + exc=IncorrectCompanyValidationError, + ) + if d.receipt_document_type == "Purchase Invoice": update_stock = frappe.db.get_value( d.receipt_document_type, d.receipt_document, "update_stock" @@ -137,6 +159,24 @@ class LandedCostVoucher(Document): _("Row {0}: Cost center is required for an item {1}").format(item.idx, item.item_code) ) + def validate_expense_accounts(self): + for t in self.taxes: + company = frappe.get_cached_value("Account", t.expense_account, "company") + + if company != self.company: + frappe.throw( + _( + "Row {0}: Expense Account {1} is linked to company {2}. Please select an account belonging to company {3}." + ).format( + t.idx, + frappe.bold(t.expense_account), + frappe.bold(company), + frappe.bold(self.company), + ), + title=_("Incorrect Account"), + exc=IncorrectCompanyValidationError, + ) + def set_total_taxes_and_charges(self): self.total_taxes_and_charges = sum(flt(d.base_amount) for d in self.get("taxes")) diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 39f9ecb915d..737df053832 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -174,6 +174,39 @@ class TestLandedCostVoucher(FrappeTestCase): self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction) self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 50.0) + def test_lcv_validates_company(self): + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + IncorrectCompanyValidationError, + ) + + company_a = "_Test Company" + company_b = "_Test Company with perpetual inventory" + + pr = make_purchase_receipt( + company=company_a, + warehouse="Stores - _TC", + qty=1, + rate=100, + ) + + lcv = make_landed_cost_voucher( + company=company_b, + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=50, + do_not_save=True, + ) + + self.assertRaises(IncorrectCompanyValidationError, lcv.validate_receipt_documents) + lcv.company = company_a + + self.assertRaises(IncorrectCompanyValidationError, lcv.validate_expense_accounts) + lcv.taxes[0].expense_account = get_expense_account(company_a) + + lcv.save() + distribute_landed_cost_on_items(lcv) + lcv.submit() + def test_landed_cost_voucher_for_zero_purchase_rate(self): "Test impact of LCV on future stock balances." from erpnext.stock.doctype.item.test_item import make_item @@ -1076,6 +1109,7 @@ def make_landed_cost_voucher(**args): lcv = frappe.new_doc("Landed Cost Voucher") lcv.company = args.company or "_Test Company" lcv.distribute_charges_based_on = args.distribute_charges_based_on or "Amount" + expense_account = get_expense_account(args.company or "_Test Company") lcv.set( "purchase_receipts", @@ -1090,16 +1124,17 @@ def make_landed_cost_voucher(**args): ], ) - lcv.set( - "taxes", - [ - { - "description": "Shipping Charges", - "expense_account": args.expense_account or "Expenses Included In Valuation - TCP1", - "amount": args.charges, - } - ], - ) + if args.charges: + lcv.set( + "taxes", + [ + { + "description": "Shipping Charges", + "expense_account": args.expense_account or expense_account, + "amount": args.charges, + } + ], + ) if not args.do_not_save: lcv.insert() @@ -1115,6 +1150,7 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company, lcv = frappe.new_doc("Landed Cost Voucher") lcv.company = company lcv.distribute_charges_based_on = "Amount" + expense_account = get_expense_account(company) lcv.set( "purchase_receipts", @@ -1134,7 +1170,7 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company, [ { "description": "Insurance Charges", - "expense_account": "Expenses Included In Valuation - TCP1", + "expense_account": expense_account, "amount": charges, } ], @@ -1149,6 +1185,11 @@ def create_landed_cost_voucher(receipt_document_type, receipt_document, company, return lcv +def get_expense_account(company): + company_abbr = frappe.get_cached_value("Company", company, "abbr") + return f"Expenses Included In Valuation - {company_abbr}" + + def distribute_landed_cost_on_items(lcv): based_on = lcv.distribute_charges_based_on.lower() total = sum(flt(d.get(based_on)) for d in lcv.get("items")) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index cc993325fb1..b9aa7065c5d 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -298,7 +298,8 @@ class MaterialRequest(BuyingController): if mr_qty_allowance: allowed_qty = flt( - (d.qty + (d.qty * (mr_qty_allowance / 100))), d.precision("ordered_qty") + (d.stock_qty + (d.stock_qty * (mr_qty_allowance / 100))), + d.precision("ordered_qty"), ) if d.ordered_qty and flt(d.ordered_qty, precision) > flt(allowed_qty, precision): diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 01816eecdb7..f2766852b64 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1556,7 +1556,7 @@ def update_common_item_properties(item, location): item.item_code = location.item_code item.s_warehouse = location.warehouse item.transfer_qty = location.picked_qty - item.qty = location.qty + item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty")) item.uom = location.uom item.conversion_factor = location.conversion_factor item.stock_uom = location.stock_uom diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index fc3df50de80..b2e2e7dac84 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1127,6 +1127,7 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate # Update Billing % based on pending accepted qty buying_settings = frappe.get_single("Buying Settings") over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance") + role_allowed_to_over_bill = frappe.db.get_single_value("Accounts Settings", "role_allowed_to_over_bill") total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0 item_wise_returned_qty = get_item_wise_returned_qty(pr_doc) @@ -1172,7 +1173,10 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False) elif amount and item.billed_amt > amount: per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100 - if per_over_billed > over_billing_allowance: + if ( + per_over_billed > over_billing_allowance + and role_allowed_to_over_bill not in frappe.get_roles() + ): frappe.throw( _("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format( item.name, frappe.bold(item.item_code), per_over_billed - over_billing_allowance diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index b0b67d8c8ea..e49a0b3b678 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -140,7 +140,8 @@ class TestQualityInspection(FrappeTestCase): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) for item in dn.items: item.sample_size = item.qty - quality_inspections = make_quality_inspections(dn.doctype, dn.name, dn.items) + + quality_inspections = make_quality_inspections(dn.company, dn.doctype, dn.name, dn.items) self.assertEqual(len(dn.items), len(quality_inspections)) # cleanup diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 54e4c4b2f56..b0936ec1381 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -718,10 +718,13 @@ class SerialandBatchBundle(Document): if rate is None and child_table in ["Delivery Note Item", "Sales Invoice Item"]: rate = frappe.db.get_value( "Packed Item", - self.voucher_detail_no, + {"parent_detail_docname": self.voucher_detail_no, "item_code": self.item_code}, "incoming_rate", ) + if rate is None: + rate = frappe.db.get_value("Packed Item", self.voucher_detail_no, "incoming_rate") + if rate is not None: is_packed_item = True @@ -794,6 +797,9 @@ class SerialandBatchBundle(Document): if parent.get("posting_time") and (not self.posting_time or self.posting_time != parent.posting_time): values_to_set["posting_time"] = parent.posting_time + if row.get("doctype") == "Packed Item" and row.get("parent_detail_docname"): + values_to_set["voucher_detail_no"] = row.get("parent_detail_docname") + if parent.doctype in [ "Delivery Note", "Purchase Receipt", @@ -1329,7 +1335,21 @@ class SerialandBatchBundle(Document): ) if not vouchers and self.voucher_type == "Delivery Note": - frappe.db.set_value("Packed Item", self.voucher_detail_no, "serial_and_batch_bundle", None) + if frappe.db.exists("Packed Item", self.voucher_detail_no): + frappe.db.set_value("Packed Item", self.voucher_detail_no, "serial_and_batch_bundle", None) + else: + packed_items = frappe.get_all( + "Packed Item", + filters={ + "parent_detail_docname": self.voucher_detail_no, + "serial_and_batch_bundle": self.name, + }, + pluck="name", + ) + + for packed_item in packed_items: + frappe.db.set_value("Packed Item", packed_item, "serial_and_batch_bundle", None) + return for voucher in vouchers: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index d7158bf1680..24aad0ddf79 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -207,6 +207,7 @@ class StockEntry(StockController): self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "transfer_qty") self.validate_warehouse() + self.validate_warehouse_of_sabb() self.validate_work_order() self.validate_bom() self.set_process_loss_qty() diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index f11854f52a9..073a3e2d117 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2258,6 +2258,53 @@ class TestStockEntry(FrappeTestCase): frappe.db.set_single_value("Manufacturing Settings", "material_consumption", original_value) + def test_qi_creation_with_naming_rule_company_condition(self): + """ + Unit test case to check the document naming rule with company condition + For Quality Inspection, when created from Stock Entry. + """ + from erpnext.accounts.report.trial_balance.test_trial_balance import create_company + from erpnext.controllers.stock_controller import make_quality_inspections + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + # create a separate company to handle document naming rule with company condition + qc_company = create_company(company_name="Test Quality Company") + + # create document naming rule based on that for Quality Inspection Doctype + qc_naming_rule = frappe.new_doc( + "Document Naming Rule", document_type="Quality Inspection", prefix="NQC.-ST-", prefix_digits=5 + ) + qc_naming_rule.append("conditions", {"field": "company", "condition": "=", "value": qc_company}) + qc_naming_rule.save() + + warehouse = create_warehouse(warehouse_name="Test QI Warehouse", company=qc_company) + item = create_item( + item_code="Test QI DNR Item", + is_stock_item=1, + ) + + # create inward stock entry + stock_entry = make_stock_entry( + item_code=item.item_code, + target=warehouse, + qty=10, + basic_rate=100, + inspection_required=True, + do_not_submit=True, + ) + + # create QI from Stock Entry and check the naming series generated. + qi = make_quality_inspections( + stock_entry.company, + stock_entry.doctype, + stock_entry.name, + stock_entry.as_dict().get("items"), + ) + self.assertEqual(qi[0], "NQC-ST-00001") + + # delete naming rule + frappe.delete_doc("Document Naming Rule", qc_naming_rule.name) + def make_serialized_item(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index cedf0872873..5c46d8e58d0 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -492,7 +492,8 @@ "fieldtype": "Link", "label": "Reference Purchase Receipt", "options": "Purchase Receipt", - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "project", @@ -616,7 +617,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-14 15:10:38.373099", + "modified": "2026-03-02 14:05:23.116017", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 90a30ef62e6..c8b559a525d 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -524,9 +524,9 @@ class StockReconciliation(StockController): if abs(difference_amount) > 0: return True - float_precision = frappe.db.get_default("float_precision") or 3 - item_dict["rate"] = flt(item_dict.get("rate"), float_precision) - item.valuation_rate = flt(item.valuation_rate, float_precision) if item.valuation_rate else None + rate_precision = item.precision("valuation_rate") + item_dict["rate"] = flt(item_dict.get("rate"), rate_precision) + item.valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None if ( (item.qty is None or item.qty == item_dict.get("qty")) and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate")) diff --git a/erpnext/stock/report/stock_analytics/stock_analytics.py b/erpnext/stock/report/stock_analytics/stock_analytics.py index 16dea3c2942..cf43519a9c8 100644 --- a/erpnext/stock/report/stock_analytics/stock_analytics.py +++ b/erpnext/stock/report/stock_analytics/stock_analytics.py @@ -17,14 +17,29 @@ from erpnext.stock.utils import is_reposting_item_valuation_in_progress def execute(filters=None): is_reposting_item_valuation_in_progress() filters = frappe._dict(filters or {}) - columns = get_columns(filters) + period_columns = get_period_columns(filters) + columns = get_columns(period_columns) data = get_data(filters) - chart = get_chart_data(columns) + chart = get_chart_data(period_columns) return columns, data, None, chart -def get_columns(filters): +def get_period_columns(filters): + period_columns = [] + ranges = get_period_date_ranges(filters) + + for _dummy, end_date in ranges: + period = get_period(end_date, filters) + + period_columns.append( + {"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120} + ) + + return period_columns + + +def get_columns(period_columns): columns = [ {"label": _("Item"), "options": "Item", "fieldname": "name", "fieldtype": "Link", "width": 140}, { @@ -45,12 +60,7 @@ def get_columns(filters): {"label": _("UOM"), "fieldname": "uom", "fieldtype": "Data", "width": 120}, ] - ranges = get_period_date_ranges(filters) - - for _dummy, end_date in ranges: - period = get_period(end_date, filters) - - columns.append({"label": _(period), "fieldname": scrub(period), "fieldtype": "Float", "width": 120}) + columns.extend(period_columns) return columns @@ -250,8 +260,8 @@ def get_data(filters): return data -def get_chart_data(columns): - labels = [d.get("label") for d in columns[5:]] +def get_chart_data(period_columns): + labels = [col.get("label") for col in period_columns] chart = {"data": {"labels": labels, "datasets": []}} chart["type"] = "line" diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index ccc5ba62b01..701035d8187 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -203,9 +203,18 @@ class StockBalanceReport: .groupby(doctype.voucher_detail_no) ) - data = query.run(as_list=True) - if data: - self.stock_reco_voucher_wise_count = frappe._dict(data) + data = query.run(as_dict=True) + if not data: + return + + for row in data: + if row.count != 1: + continue + + current_qty = frappe.db.get_value( + "Stock Reconciliation Item", row.voucher_detail_no, "current_qty" + ) + self.stock_reco_voucher_wise_count[row.voucher_detail_no] = current_qty def get_sre_reserved_qty_details(self) -> dict: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( @@ -227,7 +236,8 @@ class StockBalanceReport: if entry.voucher_type == "Stock Reconciliation" and ( not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle ): - if entry.serial_no and self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0) == 1: + if entry.serial_no and entry.voucher_detail_no in self.stock_reco_voucher_wise_count: + qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0) qty_dict.bal_qty = 0.0 qty_diff = flt(entry.actual_qty) else: diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 4d7b3d4aaa4..896b43088ad 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -382,6 +382,9 @@ class SerialBatchBundle: def submit_serial_and_batch_bundle(self): doc = frappe.get_doc("Serial and Batch Bundle", self.sle.serial_and_batch_bundle) + if self.sle.voucher_detail_no and doc.voucher_detail_no != self.sle.voucher_detail_no: + doc.voucher_detail_no = self.sle.voucher_detail_no + self.validate_actual_qty(doc) doc.flags.ignore_voucher_validation = True @@ -441,6 +444,11 @@ class SerialBatchBundle: if sle.voucher_type in ["Sales Invoice", "Delivery Note"] and sle.actual_qty < 0: customer = frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "customer") + if sle.voucher_type in ["Stock Entry"] and sle.actual_qty < 0: + purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") + if purpose in ["Disassemble", "Material Receipt"]: + status = "Inactive" + sn_table = frappe.qb.DocType("Serial No") query = ( diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index d524f644a0f..883f791d7f8 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1299,7 +1299,7 @@ class update_entries_after: else: if sle.voucher_type in ("Delivery Note", "Sales Invoice"): ref_doctype = "Packed Item" - elif sle == "Subcontracting Receipt": + elif sle.voucher_type == "Subcontracting Receipt": ref_doctype = "Subcontracting Receipt Supplied Item" else: ref_doctype = "Purchase Receipt Item Supplied"