diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/nz_standard_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/nz_standard_chart_of_accounts.json new file mode 100644 index 00000000000..410411aa670 --- /dev/null +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/nz_standard_chart_of_accounts.json @@ -0,0 +1,449 @@ +{ + "country_code": "nz", + "name": "New Zealand - Chart of Accounts with Account Numbers", + "disabled": "No", + "tree": { + "Application of Funds (Assets)": { + "Current Assets": { + "Bank Accounts": { + "Business Transaction Account": { + "account_number": "11011", + "account_type": "Bank" + }, + "Business Savings Account": { + "account_number": "11012", + "account_type": "Bank" + }, + "account_number": "11010", + "is_group": 1 + }, + "Cash on Hand": { + "account_number": "11020", + "account_type": "Cash" + }, + "Accounts Receivable": { + "Debtors": { + "account_number": "11210", + "account_type": "Receivable" + }, + "Provision for Doubtful Debts": { + "account_number": "11220" + }, + "account_number": "11200", + "is_group": 1 + }, + "Inventory": { + "Stock on Hand": { + "account_number": "11311", + "account_type": "Stock" + }, + "Work In Progress": { + "account_number": "11312", + "account_type": "Stock" + }, + "account_number": "11310", + "account_type": "Stock", + "is_group": 1 + }, + "Prepayments": { + "Prepayments": { + "account_number": "11411" + }, + "Supplier Advances": { + "account_number": "11412" + }, + "Deferred Expense": { + "account_number": "11413" + }, + "account_number": "11410", + "is_group": 1 + }, + "GST Receivable": { + "account_number": "11510", + "account_type": "Tax" + }, + "Income Tax Receivable": { + "account_number": "11520", + "account_type": "Tax" + }, + "account_number": "11000", + "is_group": 1 + }, + "Fixed Assets": { + "Plant & Equipment": { + "Plant & Equipment": { + "account_number": "16011", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation - Plant & Equipment": { + "account_number": "16012", + "account_type": "Accumulated Depreciation" + }, + "account_number": "16010", + "is_group": 1 + }, + "Motor Vehicles": { + "Motor Vehicles": { + "account_number": "16021", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation - Motor Vehicles": { + "account_number": "16022", + "account_type": "Accumulated Depreciation" + }, + "account_number": "16020", + "is_group": 1 + }, + "Office Equipment": { + "Office Equipment": { + "account_number": "16031", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation - Office Equipment": { + "account_number": "16032", + "account_type": "Accumulated Depreciation" + }, + "account_number": "16030", + "is_group": 1 + }, + "Buildings": { + "Buildings": { + "account_number": "16041", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation - Buildings": { + "account_number": "16042", + "account_type": "Accumulated Depreciation" + }, + "account_number": "16040", + "is_group": 1 + }, + "Computer Equipment": { + "Computer Equipment": { + "account_number": "16051", + "account_type": "Fixed Asset" + }, + "Accumulated Depreciation - Computer Equipment": { + "account_number": "16052", + "account_type": "Accumulated Depreciation" + }, + "account_number": "16050", + "is_group": 1 + }, + "Capital Work in Progress": { + "account_number": "16090", + "account_type": "Capital Work in Progress" + }, + "account_number": "16000", + "is_group": 1 + }, + "account_number": "10000", + "root_type": "Asset" + }, + "Source of Funds (Liabilities)": { + "Current Liabilities": { + "Accounts Payable": { + "Creditors": { + "account_number": "21010", + "account_type": "Payable" + }, + "account_number": "21000", + "is_group": 1 + }, + "Goods Received Not Invoiced": { + "account_number": "21100", + "account_type": "Stock Received But Not Billed" + }, + "Asset Received Not Invoiced": { + "account_number": "21110", + "account_type": "Asset Received But Not Billed" + }, + "Service Received Not Invoiced": { + "account_number": "21120", + "account_type": "Service Received But Not Billed" + }, + "Accrued Expenses": { + "account_number": "21200" + }, + "Wages Payable": { + "account_number": "21300" + }, + "PAYE Payable": { + "account_number": "22010" + }, + "KiwiSaver Payable": { + "account_number": "22020" + }, + "ACC Payable": { + "account_number": "22030" + }, + "Credit Cards": { + "Business Credit Card": { + "account_number": "22110" + }, + "account_number": "22100", + "is_group": 1 + }, + "Customer Advances": { + "account_number": "22200" + }, + "Deferred Revenue": { + "account_number": "22210" + }, + "Provisional Account": { + "account_number": "22220" + }, + "Tax Liabilities": { + "GST Payable": { + "account_number": "22310", + "account_type": "Tax" + }, + "GST Suspense": { + "account_number": "22320", + "account_type": "Tax" + }, + "FBT Payable": { + "account_number": "22330", + "account_type": "Tax" + }, + "Income Tax Payable": { + "account_number": "22340", + "account_type": "Tax" + }, + "account_number": "22300", + "is_group": 1 + }, + "account_number": "21500", + "is_group": 1 + }, + "Non-Current Liabilities": { + "Bank Loans": { + "Bank Loan": { + "account_number": "25011" + }, + "account_number": "25010", + "is_group": 1 + }, + "Lease Liabilities": { + "Lease Liability": { + "account_number": "25021" + }, + "account_number": "25020", + "is_group": 1 + }, + "Shareholder Loans": { + "Shareholder Loan": { + "account_number": "25031" + }, + "account_number": "25030", + "is_group": 1 + }, + "account_number": "25000", + "is_group": 1 + }, + "account_number": "20000", + "root_type": "Liability" + }, + "Equity": { + "Share Capital": { + "account_number": "31010", + "account_type": "Equity" + }, + "Drawings": { + "account_number": "31020", + "account_type": "Equity" + }, + "Current Year Earnings": { + "account_number": "35010", + "account_type": "Equity" + }, + "Retained Earnings": { + "account_number": "35020", + "account_type": "Equity" + }, + "account_number": "30000", + "root_type": "Equity" + }, + "Income": { + "Sales": { + "account_number": "41010", + "account_type": "Income Account" + }, + "Other Income": { + "Interest Income": { + "account_number": "47010", + "account_type": "Income Account" + }, + "Rounding Gain/Loss": { + "account_number": "47020", + "account_type": "Income Account" + }, + "Foreign Exchange Gain": { + "account_number": "47030", + "account_type": "Income Account" + }, + "account_number": "47000", + "is_group": 1 + }, + "account_number": "40000", + "root_type": "Income" + }, + "Expenses": { + "Cost of Goods Sold": { + "Purchases": { + "account_number": "51010", + "account_type": "Cost of Goods Sold" + }, + "Freight Inwards": { + "account_number": "51020", + "account_type": "Expenses Included In Valuation" + }, + "Duty and Landing Costs": { + "account_number": "51030", + "account_type": "Expenses Included In Valuation" + }, + "Stock Adjustment": { + "account_number": "51040", + "account_type": "Stock Adjustment" + }, + "Stock Write Off": { + "account_number": "51050", + "account_type": "Stock Adjustment" + }, + "account_number": "51000", + "account_type": "Cost of Goods Sold", + "is_group": 1 + }, + "Operating Expenses": { + "Wages & Salaries": { + "account_number": "61010", + "account_type": "Expense Account" + }, + "KiwiSaver Employer Contribution": { + "account_number": "61020", + "account_type": "Expense Account" + }, + "ACC Levies": { + "account_number": "61030", + "account_type": "Expense Account" + }, + "Rent": { + "account_number": "65010", + "account_type": "Expense Account" + }, + "Power": { + "account_number": "65020", + "account_type": "Expense Account" + }, + "Telephone": { + "account_number": "66010", + "account_type": "Expense Account" + }, + "Insurance": { + "account_number": "64010", + "account_type": "Expense Account" + }, + "Accounting Fees": { + "account_number": "64020", + "account_type": "Expense Account" + }, + "Legal Fees": { + "account_number": "64030", + "account_type": "Expense Account" + }, + "Advertising and Marketing": { + "account_number": "65030", + "account_type": "Expense Account" + }, + "Repairs and Maintenance": { + "account_number": "65040", + "account_type": "Expense Account" + }, + "Freight and Courier": { + "account_number": "65050", + "account_type": "Expense Account" + }, + "Operating Costs": { + "account_number": "65060", + "account_type": "Expense Account" + }, + "account_number": "60000", + "is_group": 1 + }, + "Depreciation and Amortisation": { + "Depreciation - Plant & Equipment": { + "account_number": "62010", + "account_type": "Depreciation" + }, + "Depreciation - Motor Vehicles": { + "account_number": "62020", + "account_type": "Depreciation" + }, + "Depreciation - Office Equipment": { + "account_number": "62030", + "account_type": "Depreciation" + }, + "Depreciation - Computer Equipment": { + "account_number": "62040", + "account_type": "Depreciation" + }, + "account_number": "62000", + "is_group": 1 + }, + "Finance Costs": { + "Bank Charges": { + "account_number": "67010", + "account_type": "Expense Account" + }, + "Interest Expense": { + "account_number": "67020", + "account_type": "Expense Account" + }, + "Rounding Off": { + "account_number": "67030", + "account_type": "Round Off" + }, + "Payment Discounts": { + "account_number": "67040", + "account_type": "Expense Account" + }, + "account_number": "67000", + "is_group": 1 + }, + "Income Tax Expense": { + "account_number": "81010", + "account_type": "Expense Account" + }, + "Foreign Exchange": { + "Exchange Gain/Loss": { + "account_number": "82010", + "account_type": "Expense Account" + }, + "Unrealized Exchange Gain/Loss": { + "account_number": "82020", + "account_type": "Expense Account" + }, + "account_number": "82000", + "is_group": 1 + }, + "Bad Debts": { + "account_number": "83010", + "account_type": "Expense Account" + }, + "Write Off": { + "account_number": "83020", + "account_type": "Expense Account" + }, + "Gain/Loss on Asset Disposal": { + "account_number": "83030", + "account_type": "Expense Account" + }, + "Expenses Included In Asset Valuation": { + "account_number": "84010", + "account_type": "Expenses Included In Asset Valuation" + }, + "account_number": "50000", + "root_type": "Expense" + } + } +} diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index f2bf3bfbf36..595dcf16de6 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -705,18 +705,20 @@ def get_ordered_amount(params): def get_other_condition(params, for_doc): - condition = f"expense_account = '{params.expense_account}'" + condition = f"expense_account = {frappe.db.escape(params.expense_account)}" budget_against_field = params.get("budget_against_field") if budget_against_field and params.get(budget_against_field): - condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'" + condition += ( + f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}" + ) date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date" start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date") end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date") - condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'" + condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}" return condition diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 21d6e4ba486..24cce464be8 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1213,7 +1213,11 @@ class JournalEntry(AccountsController): self.validate_total_debit_and_credit() def get_values(self): - cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else "" + cond = ( + f" and outstanding_amount <= {flt(self.write_off_amount)}" + if flt(self.write_off_amount) > 0 + else "" + ) if self.write_off_based_on == "Accounts Receivable": return frappe.db.sql( diff --git a/erpnext/accounts/doctype/payment_terms_template/test_payment_terms_template.py b/erpnext/accounts/doctype/payment_terms_template/test_payment_terms_template.py index 9fc91b6f2de..92d9126fe5f 100644 --- a/erpnext/accounts/doctype/payment_terms_template/test_payment_terms_template.py +++ b/erpnext/accounts/doctype/payment_terms_template/test_payment_terms_template.py @@ -2,7 +2,9 @@ # See license.txt import frappe +from frappe.utils import add_days, getdate +from erpnext.controllers.accounts_controller import get_payment_term_details from erpnext.tests.utils import ERPNextTestSuite @@ -55,6 +57,52 @@ class TestPaymentTermsTemplate(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, template.insert) + def test_no_discount_date_without_discount(self): + posting_date = "2026-05-29" + term = frappe._dict( + { + "payment_term": "_Test No Discount Term", + "invoice_portion": 100.0, + "due_date_based_on": "Day(s) after invoice date", + "credit_days": 0, + "credit_months": 0, + "discount_type": "Percentage", + "discount": 0, + "discount_validity_based_on": "Day(s) after invoice date", + "discount_validity": 0, + } + ) + + details = get_payment_term_details( + term, posting_date=posting_date, grand_total=100, base_grand_total=100 + ) + + self.assertEqual(getdate(details.due_date), getdate(posting_date)) + self.assertIsNone(details.discount_date) + + def test_discount_date_generated_with_discount(self): + posting_date = "2026-05-29" + term = frappe._dict( + { + "payment_term": "_Test Discount Term", + "invoice_portion": 100.0, + "due_date_based_on": "Day(s) after invoice date", + "credit_days": 30, + "credit_months": 0, + "discount_type": "Percentage", + "discount": 5, + "discount_validity_based_on": "Day(s) after invoice date", + "discount_validity": 10, + } + ) + + details = get_payment_term_details( + term, posting_date=posting_date, grand_total=100, base_grand_total=100 + ) + + self.assertEqual(getdate(details.due_date), getdate(add_days(posting_date, 30))) + self.assertEqual(getdate(details.discount_date), getdate(add_days(posting_date, 10))) + def test_duplicate_terms(self): template = frappe.get_doc( { diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 21d585f0a95..9fd911a2762 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -591,6 +591,25 @@ frappe.ui.form.on("Purchase Invoice", { }; }); + frm.set_query("write_off_account", function (doc) { + return { + filters: { + report_type: "Profit and Loss", + is_group: 0, + company: doc.company, + }, + }; + }); + + frm.set_query("write_off_cost_center", function (doc) { + return { + filters: { + is_group: 0, + company: doc.company, + }, + }; + }); + frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) { return { filters: { diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 32371980002..89f07935d7c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -33,7 +33,6 @@ from erpnext.accounts.party import get_due_date, get_party_account from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update_voucher_outstanding from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account -from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( @@ -279,7 +278,9 @@ class PurchaseInvoice(BuyingController): self.check_conversion_rate() self.validate_credit_to_acc() self.clear_unallocated_advances("Purchase Invoice Advance", "advances") - self.check_on_hold_or_closed_status() + self.check_for_on_hold_or_closed_status( + "Purchase Order", "purchase_order", exclude_if_field="purchase_receipt" + ) self.validate_with_previous_doc() self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "stock_qty") @@ -287,6 +288,8 @@ class PurchaseInvoice(BuyingController): self.validate_expense_account() self.set_against_expense_account() self.validate_write_off_account() + self.validate_write_off_cost_center() + from erpnext.accounts.services.billing_validation import BillingValidationService BillingValidationService(self).validate_multiple_billing("Purchase Receipt", "pr_detail", "amount") @@ -386,14 +389,6 @@ class PurchaseInvoice(BuyingController): self.party_account_currency = account.account_currency - def check_on_hold_or_closed_status(self): - check_list = [] - - for d in self.get("items"): - if d.purchase_order and d.purchase_order not in check_list and not d.purchase_receipt: - check_list.append(d.purchase_order) - check_on_hold_or_closed_status("Purchase Order", d.purchase_order) - def validate_with_previous_doc(self): super().validate_with_previous_doc( { @@ -658,6 +653,27 @@ class PurchaseInvoice(BuyingController): if self.write_off_amount and not self.write_off_account: throw(_("Please enter Write Off Account")) + if not self.write_off_account: + return + + doc = frappe.db.get_value( + "Account", self.write_off_account, ["report_type", "is_group", "company"], as_dict=True + ) + + if not doc or doc.report_type != "Profit and Loss" or doc.is_group or doc.company != self.company: + throw(_("Please enter a valid Write Off Account")) + + def validate_write_off_cost_center(self): + if not self.write_off_cost_center: + return + + doc = frappe.db.get_value( + "Cost Center", self.write_off_cost_center, ["is_group", "company"], as_dict=True + ) + + if not doc or doc.is_group or doc.company != self.company: + throw(_("Please enter a valid Write Off Cost Center")) + def check_prev_docstatus(self): for d in self.get("items"): if d.purchase_order: @@ -738,6 +754,7 @@ class PurchaseInvoice(BuyingController): def validate_for_repost(self): self.validate_write_off_account() + self.validate_write_off_cost_center() self.validate_expense_account() validate_docs_for_voucher_types(["Purchase Invoice"]) validate_docs_for_deferred_accounting([], [self.name]) @@ -848,7 +865,9 @@ class PurchaseInvoice(BuyingController): if update_outstanding == "No": update_voucher_outstanding( voucher_type=self.doctype, - voucher_no=self.return_against if cint(self.is_return) and self.return_against else self.name, + voucher_no=self.return_against + if (cint(self.is_return) and self.return_against) + else self.name, account=self.credit_to, party_type="Supplier", party=self.supplier, @@ -876,7 +895,9 @@ class PurchaseInvoice(BuyingController): super().on_cancel() PurchaseTaxWithholding(self).on_cancel() - self.check_on_hold_or_closed_status() + self.check_for_on_hold_or_closed_status( + "Purchase Order", "purchase_order", exclude_if_field="purchase_receipt" + ) if self.is_return and not self.update_billed_amount_in_purchase_order: # NOTE status updating bypassed for is_return diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py index 786cfd61273..1c4ea5c9e7f 100644 --- a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -720,6 +720,9 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): def make_payment_gl_entries(self, gl_entries): doc = self.doc if cint(doc.is_paid) and doc.cash_bank_account and doc.paid_amount: + against_voucher = doc.name + if doc.is_return and doc.return_against and not doc.update_outstanding_for_self: + against_voucher = doc.return_against bank_account_currency = get_account_currency(doc.cash_bank_account) gl_entries.append( @@ -734,9 +737,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): if doc.party_account_currency == doc.company_currency else doc.paid_amount, "debit_in_transaction_currency": doc.paid_amount, - "against_voucher": doc.return_against - if cint(doc.is_return) and doc.return_against - else doc.name, + "against_voucher": against_voucher, "against_voucher_type": doc.doctype, "cost_center": doc.cost_center, "project": doc.project, diff --git a/erpnext/accounts/doctype/sales_invoice/mapper.py b/erpnext/accounts/doctype/sales_invoice/mapper.py index cebae93fd1a..8247f55a0f6 100644 --- a/erpnext/accounts/doctype/sales_invoice/mapper.py +++ b/erpnext/accounts/doctype/sales_invoice/mapper.py @@ -9,7 +9,7 @@ from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.utils import flt, get_link_to_form, getdate -from erpnext.accounts.party import get_party_details +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, _get_party_details @frappe.whitelist() @@ -360,7 +360,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): "doctype": target_doctype, "postprocess": update_details, "set_target_warehouse": "set_from_warehouse", - "field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"], }, doctype + " Item": item_field_map, }, @@ -520,7 +520,7 @@ def update_taxes( master_doctype=None, ): # Update Party Details - party_details = get_party_details( + party_details = _get_party_details( party=party, party_type=party_type, company=company, diff --git a/erpnext/accounts/doctype/sales_invoice/services/loyalty.py b/erpnext/accounts/doctype/sales_invoice/services/loyalty.py index 706894b33b6..69d35429444 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/loyalty.py +++ b/erpnext/accounts/doctype/sales_invoice/services/loyalty.py @@ -58,28 +58,29 @@ class LoyaltyService: def delete_loyalty_point_entry(self) -> None: doc = self.doc - lp_entry = frappe.db.sql( - "select name from `tabLoyalty Point Entry` where invoice=%s", (doc.name), as_dict=1 + lp_entry = frappe.db.get_all( + "Loyalty Point Entry", filters={"invoice": doc.name, "loyalty_points": (">", 0)}, fields=["name"] ) if not lp_entry: return - against_lp_entry = frappe.db.sql( - """select name, invoice from `tabLoyalty Point Entry` - where redeem_against=%s""", - (lp_entry[0].name), - as_dict=1, + against_lp_entry = frappe.db.get_all( + "Loyalty Point Entry", + filters={"redeem_against": lp_entry[0].name}, + fields=["name", "invoice"], ) + if against_lp_entry: invoice_list = ", ".join([d.invoice for d in against_lp_entry]) frappe.throw( _( - """{} can't be cancelled since the Loyalty Points earned has been redeemed. First cancel the {} No {}""" + "{} can't be cancelled since the Loyalty Points earned has been redeemed. " + "First cancel the {} No {}" ).format(doc.doctype, doc.doctype, invoice_list) ) else: - frappe.db.sql("""delete from `tabLoyalty Point Entry` where invoice=%s""", (doc.name)) + frappe.db.delete("Loyalty Point Entry", filters={"invoice": doc.name}) self._set_loyalty_program_tier() def apply_loyalty_points(self) -> None: diff --git a/erpnext/accounts/doctype/sales_invoice/services/pos.py b/erpnext/accounts/doctype/sales_invoice/services/pos.py index e72a1c4e6fc..0dfc9ea870d 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/pos.py +++ b/erpnext/accounts/doctype/sales_invoice/services/pos.py @@ -380,32 +380,43 @@ def get_all_mode_of_payments(doc) -> list: def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict: - data = frappe.db.sql( - """ - select - mpa.default_account, mpa.parent as mop, mp.type as type - from - `tabMode of Payment Account` mpa,`tabMode of Payment` mp - where - mpa.parent = mp.name and - mpa.company = %s and - mp.enabled = 1 and - mp.name in %s - group by - mp.name - """, - (company, mode_of_payments), - as_dict=1, + ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account") + ModeOfPayment = frappe.qb.DocType("Mode of Payment") + + query = ( + frappe.qb.from_(ModeOfPaymentAccount) + .join(ModeOfPayment) + .on(ModeOfPaymentAccount.parent == ModeOfPayment.name) + .select( + ModeOfPaymentAccount.default_account, + ModeOfPaymentAccount.parent.as_("mop"), + ModeOfPayment.type.as_("type"), + ) + .where(ModeOfPaymentAccount.company == company) + .where(ModeOfPayment.enabled == 1) + .where(ModeOfPayment.name.isin(mode_of_payments)) + .groupby(ModeOfPayment.name) ) + + data = query.run(as_dict=1) + return {row.get("mop"): row for row in data} def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list: - return frappe.db.sql( - """ - select mpa.default_account, mpa.parent, mp.type as type - from `tabMode of Payment Account` mpa,`tabMode of Payment` mp - where mpa.parent = mp.name and mpa.company = %s and mp.enabled = 1 and mp.name = %s""", - (company, mode_of_payment), - as_dict=1, + ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account") + ModeOfPayment = frappe.qb.DocType("Mode of Payment") + + query = ( + frappe.qb.from_(ModeOfPayment) + .join(ModeOfPaymentAccount) + .on(ModeOfPaymentAccount.parent == ModeOfPayment.name) + .select( + ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type") + ) + .where(ModeOfPaymentAccount.company == company) + .where(ModeOfPayment.enabled == 1) + .where(ModeOfPayment.name == mode_of_payment) ) + + return query.run(as_dict=1) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index f4e810617e5..1da356d84ea 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -383,6 +383,262 @@ class TestSalesInvoice(ERPNextTestSuite): self.assertEqual(si.net_total, 3859.65) self.assertEqual(si.grand_total, 4900.00) + @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0}) + def test_inclusive_tax_zero_decimal_currency(self): + """Tax-included prices in zero-decimal currencies (e.g. JPY) must not produce + net + tax != gross due to double rounding of the net amount.""" + si = create_sales_invoice(qty=1, rate=50000, do_not_save=True) + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Tax 10%", + "rate": 10, + "included_in_print_rate": 1, + }, + ) + si.insert() + + # With currency_precision=0 (like JPY, KRW): + # 50,000 / 1.10 = 45,454.545... → net rounds to 45,455 + # Tax from unrounded net: 0.10 * 45,454.545 = 4,545.4545 → rounds to 4,545 + # The fix ensures net + tax = gross without double rounding error + self.assertEqual(si.items[0].net_amount, 45455) + self.assertEqual(si.taxes[0].tax_amount, 4545) + self.assertEqual(si.grand_total, 50000) + + def test_inclusive_tax_decimal_value_currency(self): + """Tax-included prices with decimal currency values must preserve gross total.""" + si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True) + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Tax 10%", + "rate": 10, + "included_in_print_rate": 1, + }, + ) + si.insert() + + # 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95 + # Tax from unrounded net: 0.10 * 9,090.94545... = 909.0945... → rounds to 909.09 + # If tax were calculated from rounded net instead, it would become 909.10 and grand total 10,000.05. + self.assertEqual(si.items[0].net_amount, 9090.95) + self.assertEqual(si.taxes[0].tax_amount, 909.09) + self.assertEqual(si.grand_total, 10000.04) + + @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0}) + def test_inclusive_tax_zero_decimal_currency_multiple_items(self): + """Multiple items with tax-included prices in zero-decimal currency.""" + si = create_sales_invoice(qty=1, rate=50000, do_not_save=True) + create_item("_Test Inclusive Tax Item 2") + si.append( + "items", + { + "item_code": "_Test Inclusive Tax Item 2", + "warehouse": "_Test Warehouse - _TC", + "qty": 1, + "rate": 30000, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }, + ) + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Tax 10%", + "rate": 10, + "included_in_print_rate": 1, + }, + ) + si.insert() + + # With currency_precision=0: + # Item 1: 50,000 / 1.10 = 45,454.545 → net 45,455, tax 4,545 + # Item 2: 30,000 / 1.10 = 27,272.727 → net 27,273, tax 2,727 + # Per-item: net + tax = gross holds (45455+4545=50000, 27273+2727=30000) + # Accumulated tax rounds separately: flt(7272.72, 0) = 7273 + # adjust_grand_total_for_inclusive_tax patches grand_total back to 80000 + self.assertEqual(si.items[0].net_amount, 45455) + self.assertEqual(si.items[1].net_amount, 27273) + self.assertEqual(si.net_total, 72728) + self.assertEqual(si.taxes[0].tax_amount, 7273) + self.assertEqual(si.grand_total, 80000) + + @ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0}) + def test_inclusive_tax_zero_decimal_currency_many_items(self): + """Test with 10 items (mixed 10% and 5% tax) to verify tolerance of 1 is sufficient.""" + si = create_sales_invoice(qty=1, rate=50000, do_not_save=True) + + # Add 9 more items - mix of amounts and tax rates + # Using similar amounts to maximize same-direction rounding + item_configs = [ + ("_Test Inclusive Tax Item 2", 50100, None), # 10% (default) + ("_Test Inclusive Tax Item 3", 50200, '{"_Test Account Service Tax - _TC": 5}'), # 5% + ("_Test Inclusive Tax Item 4", 50300, None), # 10% + ("_Test Inclusive Tax Item 5", 50400, '{"_Test Account Service Tax - _TC": 5}'), # 5% + ("_Test Inclusive Tax Item 6", 50500, None), # 10% + ("_Test Inclusive Tax Item 7", 50600, '{"_Test Account Service Tax - _TC": 5}'), # 5% + ("_Test Inclusive Tax Item 8", 50700, None), # 10% + ("_Test Inclusive Tax Item 9", 50800, None), # 10% + ("_Test Inclusive Tax Item 10", 50900, '{"_Test Account Service Tax - _TC": 5}'), # 5% + ] + + for item_code, rate, item_tax_rate in item_configs: + create_item(item_code) + item_dict = { + "item_code": item_code, + "warehouse": "_Test Warehouse - _TC", + "qty": 1, + "rate": rate, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + } + if item_tax_rate: + item_dict["item_tax_rate"] = item_tax_rate + si.append("items", item_dict) + + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Tax 10%", + "rate": 10, + "included_in_print_rate": 1, + }, + ) + si.insert() + + # Verify each item: net + tax = gross (within rounding tolerance) + total_gross = 0 + for item in si.items: + total_gross += item.amount + + # Grand total should match sum of gross amounts + # This tests that the tolerance of 1 handles mixed tax rates and similar amounts + self.assertEqual(si.grand_total, total_gross) + + def test_inclusive_tax_with_decimal_value_on_previous_row_amount(self): + """Inclusive tax with decimal value and On Previous Row Amount must not double-round net amount.""" + si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True) + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Tax 10%", + "rate": 10, + "included_in_print_rate": 1, + }, + ) + si.append( + "taxes", + { + "charge_type": "On Previous Row Amount", + "account_head": "_Test Account Education Cess - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Cess 5% on Tax 10%", + "rate": 5, + "row_id": 1, + "included_in_print_rate": 1, + }, + ) + si.insert() + + # Tax fractions: 10% + (5% of 10%) = 10.5% + # 50,000.55 / 1.105 = 45,249.3665... → net rounds to 45,249.37 + # Taxes are calculated from the unrounded net to keep the inclusive gross stable. + self.assertEqual(si.items[0].net_amount, 45249.37) + self.assertEqual(si.taxes[0].tax_amount, 4524.94) + self.assertEqual(si.taxes[1].tax_amount, 226.25) + self.assertEqual(si.grand_total, 50000.55) + + def test_inclusive_tax_with_decimal_value_on_previous_row_amount_non_inclusive(self): + """Non-inclusive previous-row tax should be added after inclusive tax extraction.""" + si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True) + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Tax 10%", + "rate": 10, + "included_in_print_rate": 1, + }, + ) + si.append( + "taxes", + { + "charge_type": "On Previous Row Amount", + "account_head": "_Test Account Education Cess - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Cess 5% on Tax 10%", + "rate": 5, + "row_id": 1, + "included_in_print_rate": 0, + }, + ) + si.insert() + + # Only the first tax is inclusive: + # 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95 + # Inclusive tax = 909.09, restoring the original gross of 10,000.04 + # The non-inclusive previous-row tax is added afterward: 5% of 909.09 = 45.45 + self.assertEqual(si.items[0].net_amount, 9090.95) + self.assertEqual(si.taxes[0].tax_amount, 909.09) + self.assertEqual(si.taxes[1].tax_amount, 45.45) + self.assertEqual(si.grand_total, 10045.49) + + def test_inclusive_tax_with_decimal_value_on_previous_row_total(self): + """Inclusive tax with decimal value and On Previous Row Total must not double-round net amount.""" + si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True) + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Tax 10%", + "rate": 10, + "included_in_print_rate": 1, + }, + ) + si.append( + "taxes", + { + "charge_type": "On Previous Row Total", + "account_head": "_Test Account Education Cess - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Cess 5% on Previous Total", + "rate": 5, + "row_id": 1, + "included_in_print_rate": 1, + }, + ) + si.insert() + + # Tax fractions: 10% + (5% of 110%) = 15.5% + # 50,000.55 / 1.155 = 43,290.5195... → net rounds to 43,290.52 + # Taxes are calculated from the unrounded net/previous total to keep the inclusive gross stable. + self.assertEqual(si.items[0].net_amount, 43290.52) + self.assertEqual(si.taxes[0].tax_amount, 4329.05) + self.assertEqual(si.taxes[1].tax_amount, 2380.98) + self.assertEqual(si.grand_total, 50000.55) + def test_sales_invoice_discount_amount(self): si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3]) si.discount_amount = 104.94 @@ -2662,6 +2918,34 @@ class TestSalesInvoice(ERPNextTestSuite): self.assertEqual(target_doc.company, "_Test Company 1") self.assertEqual(target_doc.supplier, "_Test Internal Supplier") + def test_inter_company_transaction_does_not_inherit_party_fields(self): + """ + Party-derived fields on SI (from Customer) must not leak into the mapped PI. + """ + si = create_sales_invoice( + company="Wind Power LLC", + customer="_Test Internal Customer", + debit_to="Debtors - WP", + warehouse="Stores - WP", + income_account="Sales - WP", + expense_account="Cost of Goods Sold - WP", + cost_center="Main - WP", + currency="USD", + do_not_save=1, + ) + si.selling_price_list = "_Test Price List Rest of the World" + si.tax_category = "_Test Tax Category 1" + si.language = "ar" + si.payment_terms_template = "_Test Payment Term Template" + si.submit() + + pi = make_inter_company_transaction("Sales Invoice", si.name) + + supplier = frappe.get_doc("Supplier", "_Test Internal Supplier") + self.assertEqual(pi.tax_category or None, supplier.tax_category or None) + self.assertEqual(pi.language or None, supplier.language or None) + self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None) + def test_inter_company_transaction_without_default_warehouse(self): "Check mapping (expense account) of inter company SI to PI in absence of default warehouse." # setup diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index fa82b3e9188..be3d142cb18 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -49,6 +49,25 @@ SALES_TRANSACTION_TYPES = { } TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES +# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the +# source and target documents belong to different parties (e.g. Sales Order → +# Purchase Order or inter-company Sales Invoice → Purchase Invoice). +CROSS_PARTY_FIELD_NO_MAP = [ + "tax_category", + "tax_id", + "tax_withholding_category", + "taxes_and_charges", + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "contact_person", + "shipping_address", + "dispatch_address", + "payment_terms_template", + "language", +] + class DuplicatePartyAccountError(frappe.ValidationError): pass @@ -65,7 +84,6 @@ def get_party_details( price_list: str | None = None, currency: str | None = None, doctype: str | None = None, - ignore_permissions: bool | None = False, fetch_payment_terms_template: bool = True, party_address: str | None = None, company_address: str | None = None, @@ -75,8 +93,6 @@ def get_party_details( ): if not party: return frappe._dict() - if not frappe.db.exists(party_type, party): - frappe.throw(_("{0}: {1} does not exists").format(party_type, party)) return _get_party_details( party, account, @@ -87,7 +103,7 @@ def get_party_details( price_list, currency, doctype, - ignore_permissions, + False, fetch_payment_terms_template, party_address, company_address, diff --git a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py index df3fc48f9e1..a9b02ddf09a 100644 --- a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py +++ b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py @@ -94,6 +94,9 @@ def get_data(filters): def get_sales_details(filters): item_details_map = {} + if filters["based_on"] not in ("Sales Order", "Sales Invoice"): + frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"])) + date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date" sales_data = frappe.db.sql( diff --git a/erpnext/accounts/services/payment_schedule.py b/erpnext/accounts/services/payment_schedule.py index 96802cd56cf..3d7ce04452b 100644 --- a/erpnext/accounts/services/payment_schedule.py +++ b/erpnext/accounts/services/payment_schedule.py @@ -177,7 +177,7 @@ class PaymentScheduleService: payment_schedule["credit_days"] = cint(schedule.credit_days) payment_schedule["credit_months"] = cint(schedule.credit_months) - if schedule.discount_validity_based_on: + if schedule.discount_validity_based_on and flt(schedule.discount): payment_schedule["discount_date"] = get_discount_date(schedule, posting_date) payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on payment_schedule["discount_validity"] = cint(schedule.discount_validity) @@ -220,6 +220,8 @@ class PaymentScheduleService: return for d in doc.get("payment_schedule"): + if not flt(d.discount): + d.discount_date = None d.validate_from_to_dates("discount_date", "due_date") if doc.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( doc.transaction_date @@ -353,12 +355,11 @@ def get_payment_term_details( term_details.outstanding = term_details.payment_amount term_details.base_outstanding = term_details.base_payment_amount - if bill_date: - term_details.due_date = get_due_date(term, bill_date) - term_details.discount_date = get_discount_date(term, bill_date) - elif posting_date: - term_details.due_date = get_due_date(term, posting_date) - term_details.discount_date = get_discount_date(term, posting_date) + has_discount = flt(term.get("discount")) + date = bill_date or posting_date + if date: + term_details.due_date = get_due_date(term, date) + term_details.discount_date = get_discount_date(term, date) if has_discount else None if posting_date and getdate(term_details.due_date) < getdate(posting_date): term_details.due_date = posting_date diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py index 2733d07a476..fbe9d7fcf7d 100644 --- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py @@ -31,7 +31,8 @@ class BulkTransactionLog(Document): log_detail = qb.DocType("Bulk Transaction Log Detail") has_records = frappe.db.sql( - f"select exists (select * from `tabBulk Transaction Log Detail` where date = '{self.name}');" + "select exists (select * from `tabBulk Transaction Log Detail` where date = %s);", + (self.name,), )[0][0] if not has_records: raise frappe.DoesNotExistError diff --git a/erpnext/buying/doctype/purchase_order/mapper.py b/erpnext/buying/doctype/purchase_order/mapper.py index e3e7cbe5bfc..f02f4ddcc42 100644 --- a/erpnext/buying/doctype/purchase_order/mapper.py +++ b/erpnext/buying/doctype/purchase_order/mapper.py @@ -10,6 +10,7 @@ from frappe.model.mapper import get_mapped_doc from frappe.utils import flt, get_link_to_form from erpnext.accounts.party import get_party_account +from erpnext.controllers.status_updater import get_allowance_for from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.item.item import get_item_defaults @@ -34,13 +35,25 @@ def make_purchase_receipt( def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 + def get_max_receivable_qty(source): + tolerance = flt(get_allowance_for(source.item_code, qty_or_amount="qty")[0]) + return flt(source.qty) * (100 + tolerance) / 100 + def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) - target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) - target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) - target.base_amount = ( - (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) - ) + received_qty = flt(obj.received_qty) + qty = flt(obj.qty) + pending_qty = qty - received_qty + + if is_unit_price_row(obj): + target.qty = qty + elif pending_qty > 0: + target.qty = pending_qty + else: + target.qty = max(get_max_receivable_qty(obj) - received_qty, 0) + + target.stock_qty = target.qty * flt(obj.conversion_factor) + target.amount = target.qty * flt(obj.rate) + target.base_amount = target.qty * flt(obj.rate) * flt(source_parent.conversion_rate) def select_item(d): filtered_items = args.get("filtered_children", []) @@ -72,7 +85,9 @@ def make_purchase_receipt( }, "postprocess": update_item, "condition": lambda doc: ( - True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) + True + if is_unit_price_row(doc) + else abs(doc.received_qty) < abs(get_max_receivable_qty(doc)) ) and doc.delivered_by_supplier != 1 and select_item(doc), diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 71501ffcc8f..bc175c8a736 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -351,9 +351,10 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( if (doc.status != "Closed") { if (doc.status != "On Hold") { if ( - doc.items + (doc.items .filter((item) => !item.delivered_by_supplier) - .some((item) => item.received_qty < item.qty) && + .some((item) => item.received_qty < item.qty) || + doc.__onload?.has_pending_receivable_qty) && allow_receipt ) { this.frm.add_custom_button( diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 088ad0a2c68..7c7604303b4 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -16,8 +16,9 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( validate_inter_company_party, ) from erpnext.accounts.party import get_party_account_currency -from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items +from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.status_updater import get_allowance_for from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, ) @@ -187,6 +188,7 @@ class PurchaseOrder(BuyingController): def onload(self): self.set_onload("can_update_items", self.can_update_items()) + self.set_onload("has_pending_receivable_qty", self.has_pending_receivable_qty()) def before_validate(self): self.set_has_unit_price_items() @@ -203,7 +205,7 @@ class PurchaseOrder(BuyingController): self.validate_supplier() self.validate_schedule_date() validate_for_items(self) - self.check_on_hold_or_closed_status() + self.check_for_on_hold_or_closed_status("Material Request", "material_request") self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "stock_qty") @@ -382,18 +384,6 @@ class PurchaseOrder(BuyingController): d.base_rate ) = d.price_list_rate = d.rate = d.last_purchase_rate = item_last_purchase_rate - # Check for Closed status - def check_on_hold_or_closed_status(self): - check_list = [] - for d in self.get("items"): - if ( - d.meta.get_field("material_request") - and d.material_request - and d.material_request not in check_list - ): - check_list.append(d.material_request) - check_on_hold_or_closed_status("Material Request", d.material_request) - def update_ordered_qty(self, po_item_rows=None): """update requested qty (before ordered_qty is updated)""" item_wh_list = [] @@ -475,7 +465,7 @@ class PurchaseOrder(BuyingController): self.set_received_qty_to_zero_for_drop_ship_items() self.update_receiving_percentage() - self.check_on_hold_or_closed_status() + self.check_for_on_hold_or_closed_status("Material Request", "material_request") self.db_set("status", "Cancelled") @@ -660,6 +650,19 @@ class PurchaseOrder(BuyingController): return result + def has_pending_receivable_qty(self) -> bool: + """Return True if any non-drop-ship item can still be received, + considering the configured over_delivery_receipt_allowance. + """ + for item in self.get("items", []): + if item.delivered_by_supplier: + continue + tolerance = flt(get_allowance_for(item.item_code, qty_or_amount="qty")[0]) + max_receivable_qty = flt(item.qty) * (100 + tolerance) / 100 + if abs(flt(item.received_qty)) < abs(max_receivable_qty): + return True + return False + def update_ordered_qty_in_so_for_removed_items(self, removed_items): """ Updates ordered_qty in linked SO when item rows are removed using Update Items diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 3d0fa73edae..51ec360f8bf 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -98,6 +98,60 @@ class TestPurchaseOrder(ERPNextTestSuite): po.load_from_db() self.assertEqual(po.get("items")[0].received_qty, 4) + def test_make_purchase_receipt_respects_over_receipt_allowance(self): + """make_purchase_receipt must include fully-received PO lines when + over_delivery_receipt_allowance permits further receipt. + + Regression test for #55246: the mapper dropped rows once + received_qty >= qty, ignoring the configured tolerance. + """ + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + + # 50% tolerance — 10 ordered allows up to 15 received + frappe.db.set_value("Item", "_Test Item", "over_delivery_receipt_allowance", 50) + try: + po = create_purchase_order() + create_pr_against_po(po.name, received_qty=10) + + po.load_from_db() + self.assertEqual(po.get("items")[0].received_qty, 10) + + # onload must flag pending receivable qty so the UI keeps the + # "Create > Purchase Receipt" button visible even at per_received = 100 + po.run_method("onload") + self.assertTrue( + po.get_onload("has_pending_receivable_qty"), + "onload should flag pending receivable qty while tolerance is available", + ) + + # Re-mapping the same PO must yield a PR with the row present + # and qty pre-filled to the remaining tolerance (15 - 10 = 5) + pr = make_purchase_receipt(po.name) + self.assertEqual( + len(pr.get("items")), 1, "Fully-received row dropped despite available tolerance" + ) + self.assertEqual(pr.get("items")[0].item_code, "_Test Item") + self.assertEqual(pr.get("items")[0].qty, 5) + self.assertEqual(pr.get("items")[0].purchase_order_item, po.get("items")[0].name) + + # Tolerance exhausted → row must be filtered out as before + create_pr_against_po(po.name, received_qty=5) + po.load_from_db() + self.assertEqual(po.get("items")[0].received_qty, 15) + + po.run_method("onload") + self.assertFalse( + po.get_onload("has_pending_receivable_qty"), + "onload should clear pending receivable flag once tolerance is exhausted", + ) + + pr_empty = make_purchase_receipt(po.name) + self.assertEqual( + len(pr_empty.get("items")), 0, "Row should be dropped once tolerance is exhausted" + ) + finally: + frappe.db.set_value("Item", "_Test Item", "over_delivery_receipt_allowance", 0) + def test_ordered_qty_against_pi_with_update_stock(self): existing_ordered_qty = get_ordered_qty() po = create_purchase_order() diff --git a/erpnext/buying/doctype/request_for_quotation/mapper.py b/erpnext/buying/doctype/request_for_quotation/mapper.py index 05ba754812f..e414d943fb0 100644 --- a/erpnext/buying/doctype/request_for_quotation/mapper.py +++ b/erpnext/buying/doctype/request_for_quotation/mapper.py @@ -8,7 +8,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -from erpnext.accounts.party import get_party_account_currency, get_party_details +from erpnext.accounts.party import _get_party_details, get_party_account_currency from erpnext.stock.doctype.material_request.mapper import set_missing_values @@ -19,7 +19,7 @@ def make_supplier_quotation_from_rfq( def postprocess(source, target_doc): if for_supplier: target_doc.supplier = for_supplier - args = get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True) + args = _get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True) target_doc.currency = args.currency or get_party_account_currency( "Supplier", for_supplier, source.company ) diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index 48684f49739..8f41296f57e 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -118,12 +118,12 @@ class TestSupplier(ERPNextTestSuite): self.assertEqual(supplier.country, "Greece") def test_party_details_tax_category(self): - from erpnext.accounts.party import get_party_details + from erpnext.accounts.party import _get_party_details frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing") # Tax Category without Address - details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier") + details = _get_party_details("_Test Supplier With Tax Category", party_type="Supplier") self.assertEqual(details.tax_category, "_Test Tax Category 1") address = frappe.get_doc( @@ -138,7 +138,7 @@ class TestSupplier(ERPNextTestSuite): ).insert() # Tax Category with Address - details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier") + details = _get_party_details("_Test Supplier With Tax Category", party_type="Supplier") self.assertEqual(details.tax_category, "_Test Tax Category 2") # Rollback diff --git a/erpnext/buying/utils.py b/erpnext/buying/utils.py index 7b80bf08290..f661ecb5d3d 100644 --- a/erpnext/buying/utils.py +++ b/erpnext/buying/utils.py @@ -113,7 +113,14 @@ def check_on_hold_or_closed_status(doctype, docname) -> None: status = frappe.db.get_value(doctype, docname, "status") if status in ("Closed", "On Hold"): - frappe.throw(_("{0} {1} status is {2}").format(doctype, docname, status), frappe.InvalidStatusError) + frappe.throw( + _("{0} {1} status is {2}.").format( + frappe.bold(_(doctype)), + frappe.bold(docname), + frappe.bold(_(status)), + ), + frappe.InvalidStatusError, + ) @frappe.whitelist() diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7ed819167ea..4dcb6b00b49 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -156,7 +156,7 @@ class AccountsController(TransactionBase): if not get_meta(self.doctype).has_field("outstanding_amount"): return - if self.get("is_return") and self.return_against and not self.get("is_pos"): + if self.get("is_return") and self.return_against and not (self.get("is_pos") or self.get("is_paid")): against_voucher_outstanding = frappe.get_value( self.doctype, self.return_against, "outstanding_amount" ) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index e5847c449a9..1fac4f8b216 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -13,7 +13,7 @@ from frappe.utils.data import nowtime import erpnext from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget -from erpnext.accounts.party import get_party_details +from erpnext.accounts.party import _get_party_details from erpnext.buying.utils import update_last_purchase_rate, validate_for_items from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.sales_and_purchase_return import get_rate_for_return @@ -213,7 +213,7 @@ class BuyingController(SubcontractingController): # set contact and address details for supplier, if they are not mentioned if getattr(self, "supplier", None): self.update_if_missing( - get_party_details( + _get_party_details( self.supplier, party_type="Supplier", doctype=self.doctype, @@ -683,19 +683,6 @@ class BuyingController(SubcontractingController): ) ) - def check_for_on_hold_or_closed_status(self, ref_doctype, ref_fieldname): - for d in self.get("items"): - if d.get(ref_fieldname): - status = frappe.db.get_value(ref_doctype, d.get(ref_fieldname), "status") - if status in ("Closed", "On Hold"): - frappe.throw( - _("{ref_doctype} {ref_name} is {status}.").format( - ref_doctype=frappe.bold(_(ref_doctype)), - ref_name=frappe.bold(d.get(ref_fieldname)), - status=frappe.bold(_(status)), - ) - ) - def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False): self.update_ordered_and_reserved_qty() diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 4a7cae8fcfd..9ac3f3b6977 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -469,11 +469,9 @@ class SellingController(StockController): return so_qty, so_warehouse def check_sales_order_on_hold_or_close(self, ref_fieldname): - for d in self.get("items"): - if d.get(ref_fieldname): - status = frappe.db.get_value("Sales Order", d.get(ref_fieldname), "status") - if status in ("Closed", "On Hold") and not self.is_return: - frappe.throw(_("Sales Order {0} is {1}").format(d.get(ref_fieldname), status)) + if self.is_return: + return + self.check_for_on_hold_or_closed_status("Sales Order", ref_fieldname) def update_reserved_qty(self): so_map = {} diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 06cc57d6287..1b0ee5cf6b7 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -524,9 +524,9 @@ class StatusUpdater(Document): for args in self.status_updater: # condition to include current record (if submit or no if cancel) if self.docstatus == 1: - args["cond"] = " or parent='%s'" % self.name.replace('"', '"') + args["cond"] = " or parent=%s" % frappe.db.escape(self.name) else: - args["cond"] = " and parent!='%s'" % self.name.replace('"', '"') + args["cond"] = " and parent!=%s" % frappe.db.escape(self.name) self._update_children(args, update_modified) @@ -556,9 +556,10 @@ class StatusUpdater(Document): args["second_source_condition"] = frappe.db.sql( """ select ifnull((select sum({second_source_field}) from `tab{second_source_dt}` - where `{second_join_field}`='{detail_id}' + where `{second_join_field}`=%(detail_id)s and (`tab{second_source_dt}`.docstatus=1) - {second_source_extra_cond}), 0) """.format(**args) + {second_source_extra_cond}), 0) """.format(**args), + {"detail_id": args["detail_id"]}, )[0][0] if args["detail_id"]: @@ -569,9 +570,10 @@ class StatusUpdater(Document): frappe.db.sql( """ (select ifnull(sum({source_field}), 0) - from `tab{source_dt}` where `{join_field}`='{detail_id}' + from `tab{source_dt}` where `{join_field}`=%(detail_id)s and (docstatus=1 {cond}) {extra_cond}) - """.format(**args) + """.format(**args), + {"detail_id": args["detail_id"]}, )[0][0] or 0.0 ) @@ -582,7 +584,8 @@ class StatusUpdater(Document): frappe.db.sql( """update `tab{target_dt}` set {target_field} = {source_dt_value} {update_modified} - where name='{detail_id}'""".format(**args) + where name=%(detail_id)s""".format(**args), + {"detail_id": args["detail_id"]}, ) @staticmethod diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 693f48c9c64..ce7422ed2d5 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1803,6 +1803,43 @@ class StockController(AccountsController): qty -= working_qty + def check_for_on_hold_or_closed_status( + self, ref_doctype: str, ref_fieldname: str, exclude_if_field: str | None = None + ) -> None: + def _include(d): + return d.get(ref_fieldname) and not (exclude_if_field and d.get(exclude_if_field)) + + included = [(d, d.get(ref_fieldname)) for d in self.get("items") if _include(d)] + if not included: + return + + status_map = { + r.name: r.status + for r in frappe.get_all( + ref_doctype, + filters={"name": ["in", {name for _, name in included}]}, + fields=["name", "status"], + ) + } + + errors = [] + seen = set() + for _d, ref_name in included: + if ref_name in seen: + continue + seen.add(ref_name) + if (status := status_map.get(ref_name)) in ("Closed", "On Hold"): + errors.append( + _("{ref_doctype} {ref_name} status is {status}.").format( + ref_doctype=frappe.bold(_(ref_doctype)), + ref_name=frappe.bold(ref_name), + status=frappe.bold(_(status)), + ) + ) + + if errors: + frappe.throw("
".join(errors), frappe.InvalidStatusError) + @frappe.whitelist() def show_accounting_ledger_preview(company: str, doctype: str, docname: str): diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index d1afe78cfc3..7b8159d6df5 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -304,6 +304,7 @@ class calculate_taxes_and_totals: return for item in self.doc.items: + item._unrounded_net_amount = None item_tax_map = self._load_item_tax_rate(item.item_tax_rate) cumulated_tax_fraction = 0 total_inclusive_tax_amount_per_qty = 0 @@ -331,7 +332,8 @@ class calculate_taxes_and_totals: ): amount = flt(item.amount) - total_inclusive_tax_amount_per_qty - item.net_amount = flt(amount / (1 + cumulated_tax_fraction), item.precision("net_amount")) + item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction) + item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount")) item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate")) item.discount_percentage = flt( item.discount_percentage, item.precision("discount_percentage") @@ -541,7 +543,9 @@ class calculate_taxes_and_totals: actual_breakup = tax._total_tax_breakup diff = flt(expected_amount - actual_breakup, 5) - if abs(diff) <= 0.5: + # TODO: fix rounding difference issues + # Allow up to 1 for zero-precision currencies (e.g. JPY, KRW) + if abs(diff) <= (1 if tax.precision("tax_amount") == 0 else 0.5): detail_row = self.doc._item_wise_tax_details[last_idx] detail_row["amount"] = flt(detail_row["amount"] + diff, 5) @@ -600,7 +604,16 @@ class calculate_taxes_and_totals: elif tax.charge_type == "On Net Total": if tax.account_head in item_tax_map: current_net_amount = item.net_amount - current_tax_amount = (tax_rate / 100.0) * item.net_amount + + # Use unrounded net for inclusive taxes to avoid double rounding + if ( + cint(tax.included_in_print_rate) + and not self.discount_amount_applied + and item._unrounded_net_amount is not None + ): + current_tax_amount = (tax_rate / 100.0) * item._unrounded_net_amount + else: + current_tax_amount = (tax_rate / 100.0) * item.net_amount elif tax.charge_type == "On Previous Row Amount": current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].tax_amount_for_current_item current_tax_amount = (tax_rate / 100.0) * current_net_amount diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index 86da88f0072..8d8c0d19878 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.modules.utils import get_module_app -from frappe.utils import flt, has_common +from frappe.utils import cint, flt, has_common from frappe.utils.user import is_website_user @@ -179,10 +179,13 @@ def get_list_for_transactions( def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_page_length): data = frappe.db.sql( - """select distinct parent as name, supplier from `tab{doctype}` - where supplier = '{supplier}' and docstatus=1 order by creation desc limit {start}, {len}""".format( - doctype=parties_doctype, supplier=parties[0], start=limit_start, len=limit_page_length - ), + f"""select distinct parent as name, supplier from `tab{parties_doctype}` + where supplier = %(supplier)s and docstatus=1 order by creation desc limit %(start)s, %(len)s""", + { + "supplier": parties[0], + "start": cint(limit_start), + "len": cint(limit_page_length), + }, as_dict=1, ) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 1c27a6ba84c..b9b35e5bdec 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -24,6 +24,7 @@ from frappe.utils import ( ) from pypika import functions as fn +from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.manufacturing.doctype.bom.bom import ( get_bom_item_rate, get_bom_items_as_dict, @@ -438,7 +439,7 @@ class WorkOrder(Document): production_item = main_item_code if self.sales_order: - self.check_sales_order_on_hold_or_close() + check_on_hold_or_closed_status("Sales Order", self.sales_order) SalesOrder = frappe.qb.DocType("Sales Order") SalesOrderItem = frappe.qb.DocType("Sales Order Item") @@ -494,11 +495,6 @@ class WorkOrder(Document): else: frappe.throw(_("Sales Order {0} is not valid").format(self.sales_order)) - def check_sales_order_on_hold_or_close(self): - status = frappe.db.get_value("Sales Order", self.sales_order, "status") - if status in ("Closed", "On Hold"): - frappe.throw(_("Sales Order {0} is {1}").format(self.sales_order, status)) - def set_default_warehouse(self): if not self.wip_warehouse and not self.skip_transfer: self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse") diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index e3074f711ef..cb6c32a41b0 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -258,6 +258,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (has_inclusive_tax == false) return; $.each(this.frm.doc.items || [], function (n, item) { + item._unrounded_net_amount = null; var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); var cumulated_tax_fraction = 0.0; var total_inclusive_tax_amount_per_qty = 0; @@ -284,7 +285,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { (total_inclusive_tax_amount_per_qty || cumulated_tax_fraction) ) { var amount = flt(item.amount) - total_inclusive_tax_amount_per_qty; - item.net_amount = flt(amount / (1 + cumulated_tax_fraction), precision("net_amount", item)); + item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction); + item.net_amount = flt(item._unrounded_net_amount, precision("net_amount", item)); item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0; me.set_in_company_currency(item, ["net_rate", "net_amount"]); @@ -567,7 +569,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (tax.account_head in item_tax_map) { current_net_amount = item.net_amount; } - current_tax_amount = (tax_rate / 100.0) * item.net_amount; + // Use unrounded net for inclusive taxes to avoid double rounding + var net_for_tax = + cint(tax.included_in_print_rate) && + !this.discount_amount_applied && + item._unrounded_net_amount !== null + ? item._unrounded_net_amount + : item.net_amount; + current_tax_amount = (tax_rate / 100.0) * net_for_tax; } else if (tax.charge_type == "On Previous Row Amount") { current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item; current_tax_amount = diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index a20b72805aa..4ed94683bec 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -55,7 +55,7 @@ class TestCustomer(ERPNextTestSuite): doc.delete() def test_party_details(self): - from erpnext.accounts.party import get_party_details + from erpnext.accounts.party import _get_party_details to_check = { "selling_price_list": None, @@ -77,7 +77,7 @@ class TestCustomer(ERPNextTestSuite): "Contact", "_Test Contact for _Test Customer-_Test Customer", "is_primary_contact", 1 ) - details = get_party_details("_Test Customer") + details = _get_party_details("_Test Customer") for key, value in to_check.items(): val = details.get(key) @@ -87,10 +87,10 @@ class TestCustomer(ERPNextTestSuite): self.assertEqual(value, val) def test_party_details_tax_category(self): - from erpnext.accounts.party import get_party_details + from erpnext.accounts.party import _get_party_details # Tax Category without Address - details = get_party_details("_Test Customer With Tax Category") + details = _get_party_details("_Test Customer With Tax Category") self.assertEqual(details.tax_category, "_Test Tax Category 1") frappe.get_doc( @@ -122,13 +122,13 @@ class TestCustomer(ERPNextTestSuite): # Tax Category from Billing Address settings.determine_address_tax_category_from = "Billing Address" settings.save() - details = get_party_details("_Test Customer With Tax Category") + details = _get_party_details("_Test Customer With Tax Category") self.assertEqual(details.tax_category, "_Test Tax Category 2") # Tax Category from Shipping Address settings.determine_address_tax_category_from = "Shipping Address" settings.save() - details = get_party_details("_Test Customer With Tax Category") + details = _get_party_details("_Test Customer With Tax Category") self.assertEqual(details.tax_category, "_Test Tax Category 3") # Rollback diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index fb2d1b15907..aa3381ccfb8 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -12,7 +12,7 @@ from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import Sum from frappe.utils import add_days, cint, flt, nowdate, strip_html -from erpnext.accounts.party import get_party_account +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_items_for_material_requests, get_sales_orders, @@ -669,7 +669,6 @@ def make_purchase_order( target.shipping_rule = "" target.tc_name = "" target.terms = "" - target.payment_terms_template = "" target.payment_schedule = [] default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") @@ -736,16 +735,7 @@ def make_purchase_order( { "Sales Order": { "doctype": "Purchase Order", - "field_no_map": [ - "address_display", - "contact_display", - "contact_mobile", - "contact_email", - "contact_person", - "taxes_and_charges", - "shipping_address", - "dispatch_address", - ], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP], "validation": {"docstatus": ["=", 1]}, }, "Sales Order Item": { diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 17eeb64e69c..109716ab27e 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -24,6 +24,7 @@ from erpnext.selling.doctype.sales_order.mapper import ( make_delivery_note, make_material_request, make_production_plan, + make_purchase_order, make_raw_material_request, make_sales_invoice, make_work_orders, @@ -1165,9 +1166,6 @@ class TestSalesOrder(ERPNextTestSuite): def test_drop_shipping(self): from erpnext.buying.doctype.purchase_order.purchase_order import update_status - from erpnext.selling.doctype.sales_order.mapper import ( - make_purchase_order, - ) from erpnext.selling.doctype.sales_order.sales_order import update_status as so_update_status # make items @@ -1261,9 +1259,6 @@ class TestSalesOrder(ERPNextTestSuite): so.cancel() def test_drop_shipping_partial_order(self): - from erpnext.selling.doctype.sales_order.mapper import ( - make_purchase_order, - ) from erpnext.selling.doctype.sales_order.sales_order import update_status as so_update_status # make items @@ -1321,10 +1316,6 @@ class TestSalesOrder(ERPNextTestSuite): def test_drop_shipping_full_for_default_suppliers(self): """Test if multiple POs are generated in one go against different default suppliers.""" - from erpnext.selling.doctype.sales_order.mapper import ( - make_purchase_order, - ) - if not frappe.db.exists("Item", "_Test Item for Drop Shipping 1"): make_item("_Test Item for Drop Shipping 1", {"is_stock_item": 1, "delivered_by_supplier": 1}) @@ -1365,8 +1356,6 @@ class TestSalesOrder(ERPNextTestSuite): Tests if the the Product Bundles in the Items table of Sales Orders are replaced with their child items(from the Packed Items table) on creating a Purchase Order from it. """ - from erpnext.selling.doctype.sales_order.mapper import make_purchase_order - product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1}) @@ -1395,8 +1384,6 @@ class TestSalesOrder(ERPNextTestSuite): """ Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order """ - from erpnext.selling.doctype.sales_order.mapper import make_purchase_order - product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1}) @@ -2666,8 +2653,6 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(so.status, "To Deliver and Bill") def test_item_tax_transfer_from_sales_to_purchase(self): - from erpnext.selling.doctype.sales_order.mapper import make_purchase_order - item_tax = frappe.new_doc("Item Tax Template") item_tax.title = "Test Item Tax Template" item_tax.company = "_Test Company" @@ -2697,6 +2682,33 @@ class TestSalesOrder(ERPNextTestSuite): po.submit() self.assertEqual(po.taxes[0].tax_amount, 2) + def test_make_purchase_order_does_not_inherit_party_fields(self): + """ + Customer-derived fields must not leak from a drop-ship SO into the PO. + """ + so_items = [ + { + "item_code": "_Test Item", + "warehouse": "", + "qty": 1, + "rate": 100, + "delivered_by_supplier": 1, + "supplier": "_Test Supplier", + } + ] + so = make_sales_order(item_list=so_items, do_not_submit=True) + so.tax_category = "_Test Tax Category 1" + so.language = "ar" + so.payment_terms_template = "_Test Payment Term Template" + so.submit() + + po = make_purchase_order(so.name, selected_items=so_items)[0] + + supplier = frappe.get_doc("Supplier", "_Test Supplier") + self.assertEqual(po.tax_category or None, supplier.tax_category or None) + self.assertEqual(po.language or None, supplier.language or None) + self.assertEqual(po.payment_terms_template or None, supplier.payment_terms or None) + def test_pending_quantity_after_update_item_during_invoice_creation(self): so = make_sales_order(qty=30, rate=100) diff --git a/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py b/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py index d9caa9b8bad..f6783abfbe5 100644 --- a/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py +++ b/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py @@ -7,7 +7,7 @@ from frappe import _, qb from frappe.query_builder import Criterion from erpnext import get_default_company -from erpnext.accounts.party import get_party_details +from erpnext.accounts.party import _get_party_details def execute(filters=None): @@ -125,7 +125,7 @@ def get_data(filters=None): def get_customer_details(filters): - customer_details = get_party_details(party=filters.get("customer"), party_type="Customer") + customer_details = _get_party_details(party=filters.get("customer"), party_type="Customer") customer_details.update( {"company": get_default_company(), "price_list": customer_details.get("selling_price_list")} ) diff --git a/erpnext/selling/report/inactive_customers/inactive_customers.py b/erpnext/selling/report/inactive_customers/inactive_customers.py index 7e4ddc128ac..d21d11b2447 100644 --- a/erpnext/selling/report/inactive_customers/inactive_customers.py +++ b/erpnext/selling/report/inactive_customers/inactive_customers.py @@ -14,6 +14,9 @@ def execute(filters=None): days_since_last_order = filters.get("days_since_last_order") doctype = filters.get("doctype") + if doctype not in ("Sales Order", "Sales Invoice"): + frappe.throw(_("Invalid value {0} for 'Doctype'").format(doctype)) + if cint(days_since_last_order) <= 0: frappe.throw(_("'Days Since Last Order' must be greater than or equal to zero")) diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index 2aac07ce3b5..e36690b4384 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -497,14 +497,16 @@ class Analytics: break def get_groups(self): - if self.filters.tree_type == "Territory": - parent = "parent_territory" - if self.filters.tree_type == "Customer Group": - parent = "parent_customer_group" - if self.filters.tree_type == "Item Group": - parent = "parent_item_group" - if self.filters.tree_type == "Supplier Group": - parent = "parent_supplier_group" + parent_field_map = { + "Territory": "parent_territory", + "Customer Group": "parent_customer_group", + "Item Group": "parent_item_group", + "Supplier Group": "parent_supplier_group", + } + if self.filters.tree_type not in parent_field_map: + frappe.throw(_("Invalid Tree Type {0}").format(self.filters.tree_type)) + + parent = parent_field_map[self.filters.tree_type] self.depth_map = frappe._dict() @@ -523,6 +525,9 @@ class Analytics: def get_teams(self): self.depth_map = frappe._dict() + if not frappe.db.exists("DocType", self.filters.doc_type): + frappe.throw(_("Invalid Document Type {0}").format(self.filters.doc_type)) + self.group_entries = frappe.db.sql( f""" select * from (select "Order Types" as name, 0 as lft, 2 as rgt, '' as parent union select distinct order_type as name, 1 as lft, 1 as rgt, "Order Types" as parent diff --git a/erpnext/setup/doctype/authorization_control/authorization_control.py b/erpnext/setup/doctype/authorization_control/authorization_control.py index ef703de698f..98bc2aa7d9f 100644 --- a/erpnext/setup/doctype/authorization_control/authorization_control.py +++ b/erpnext/setup/doctype/authorization_control/authorization_control.py @@ -120,7 +120,9 @@ class AuthorizationControl(TransactionBase): if val == 1: add_cond += " and system_user = {}".format(frappe.db.escape(session["user"])) elif val == 2: - add_cond += " and system_role IN %s" % ("('" + "','".join(frappe.get_roles()) + "')") + add_cond += " and system_role IN (%s)" % ", ".join( + frappe.db.escape(r) for r in frappe.get_roles() + ) else: add_cond += " and ifnull(system_user,'') = '' and ifnull(system_role,'') = ''" @@ -206,8 +208,8 @@ class AuthorizationControl(TransactionBase): and docstatus != 2 """.format( "%s", - "'" + "','".join(frappe.get_roles()) + "'", - "'" + "','".join(final_based_on) + "'", + ", ".join(frappe.db.escape(r) for r in frappe.get_roles()), + ", ".join(frappe.db.escape(b) for b in final_based_on), "%s", ), (doctype_name, company), diff --git a/erpnext/stock/doctype/delivery_note/mapper.py b/erpnext/stock/doctype/delivery_note/mapper.py index 782cd3afd7b..81fdf04f6fd 100644 --- a/erpnext/stock/doctype/delivery_note/mapper.py +++ b/erpnext/stock/doctype/delivery_note/mapper.py @@ -13,7 +13,7 @@ from frappe.query_builder import DocType from frappe.query_builder.functions import Abs, Sum from frappe.utils import flt -from erpnext.accounts.party import get_due_date +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes @@ -558,8 +558,7 @@ def make_inter_company_transaction(doctype: str, source_name: str, target_doc=No doctype: { "doctype": target_doctype, "postprocess": update_details, - "field_no_map": ["taxes_and_charges", "set_warehouse"], - "field_map": {"shipping_address_name": "shipping_address"}, + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"], }, doctype + " Item": { "doctype": target_doctype + " Item", diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index f2b8b1856d2..c020ab35f90 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -250,7 +250,7 @@ class MaterialRequest(BuyingController): def check_modified_date(self): mod_db = frappe.db.sql("""select modified from `tabMaterial Request` where name = %s""", self.name) - date_diff = frappe.db.sql(f"""select TIMEDIFF('{mod_db[0][0]}', '{cstr(self.modified)}')""") + date_diff = frappe.db.sql("""select TIMEDIFF(%s, %s)""", (mod_db[0][0], cstr(self.modified))) if date_diff and date_diff[0][0]: frappe.throw(_("{0} {1} has been modified. Please refresh.").format(_(self.doctype), self.name)) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index dadf7e405df..0ec7cfbac16 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -13,7 +13,6 @@ from pypika import functions as fn import erpnext from erpnext.accounts.utils import get_account_currency from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled -from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation @@ -255,7 +254,7 @@ class PurchaseReceipt(BuyingController): self.validate_cwip_accounts() self.validate_provisional_expense_account() - self.check_on_hold_or_closed_status() + self.check_for_on_hold_or_closed_status("Purchase Order", "purchase_order") if getdate(self.posting_date) > getdate(nowdate()): throw(_("Posting Date cannot be future date")) @@ -363,14 +362,6 @@ class PurchaseReceipt(BuyingController): po_qty, po_warehouse = frappe.db.get_value("Purchase Order Item", po_detail, ["qty", "warehouse"]) return po_qty, po_warehouse - # Check for Closed status - def check_on_hold_or_closed_status(self): - check_list = [] - for d in self.get("items"): - if d.meta.get_field("purchase_order") and d.purchase_order and d.purchase_order not in check_list: - check_list.append(d.purchase_order) - check_on_hold_or_closed_status("Purchase Order", d.purchase_order) - # on submit def on_submit(self): super().on_submit() @@ -446,7 +437,7 @@ class PurchaseReceipt(BuyingController): def on_cancel(self): super().on_cancel() - self.check_on_hold_or_closed_status() + self.check_for_on_hold_or_closed_status("Purchase Order", "purchase_order") # Check if Purchase Invoice has been submitted against current Purchase Order submitted = frappe.db.sql( """select t1.name diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 0a0759b07ef..4c08150605c 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1051,6 +1051,40 @@ class TestPurchaseReceipt(ERPNextTestSuite): pr.cancel() + def test_inter_company_purchase_receipt_does_not_inherit_party_fields(self): + """ + Party-derived fields on DN (from Customer) must not leak into the mapped PR. + """ + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + prepare_data_for_internal_transfer() + + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + + dn = create_delivery_note( + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=1, + rate=100, + warehouse="Stores - TCP1", + target_warehouse="Work In Progress - TCP1", + do_not_submit=True, + ) + # Stamp customer-side party fields onto the DN + dn.tax_category = "_Test Tax Category 2" + dn.language = "ar" + dn.submit() + + pr = make_inter_company_purchase_receipt(dn.name) + + supplier = frappe.get_doc("Supplier", "_Test Internal Supplier 2") + self.assertEqual(pr.tax_category or None, supplier.tax_category or None) + self.assertEqual(pr.language or None, supplier.language or None) + def test_lcv_for_internal_transfer(self): from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index ebfa039f82a..2f853252723 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -284,7 +284,7 @@ def set_stock_balance_as_per_serial_no( if not posting_time: posting_time = nowtime() - condition = " and item.name='%s'" % item_code.replace("'", "'") if item_code else "" + condition = " and item.name=%s" % frappe.db.escape(item_code, percent=False) if item_code else "" bin = frappe.db.sql( """select bin.item_code, bin.warehouse, bin.actual_qty, item.stock_uom diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 651da0b7697..a998b611df9 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -10,7 +10,6 @@ from frappe.query_builder.functions import Sum from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate import erpnext -from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults @@ -218,9 +217,7 @@ class SubcontractingReceipt(SubcontractingController): self.create_raw_materials_supplied_or_received() def validate_closed_subcontracting_order(self): - for item in self.items: - if item.subcontracting_order: - check_on_hold_or_closed_status("Subcontracting Order", item.subcontracting_order) + self.check_for_on_hold_or_closed_status("Subcontracting Order", "subcontracting_order") def update_job_card(self): for row in self.get("items"):