Merge remote-tracking branch 'upstream/develop' into erpnext-refactoring

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
#	erpnext/accounts/doctype/sales_invoice/sales_invoice.py
#	erpnext/buying/doctype/purchase_order/purchase_order.py
#	erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
#	erpnext/controllers/accounts_controller.py
#	erpnext/selling/doctype/sales_order/sales_order.py
#	erpnext/selling/doctype/sales_order/test_sales_order.py
#	erpnext/stock/doctype/delivery_note/delivery_note.py
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
#	erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
This commit is contained in:
Nabin Hait
2026-06-03 13:23:03 +05:30
44 changed files with 1227 additions and 207 deletions

View File

@@ -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"
}
}
}

View File

@@ -705,18 +705,20 @@ def get_ordered_amount(params):
def get_other_condition(params, for_doc): 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") budget_against_field = params.get("budget_against_field")
if budget_against_field and 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" 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") 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") 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 return condition

View File

@@ -1213,7 +1213,11 @@ class JournalEntry(AccountsController):
self.validate_total_debit_and_credit() self.validate_total_debit_and_credit()
def get_values(self): 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": if self.write_off_based_on == "Accounts Receivable":
return frappe.db.sql( return frappe.db.sql(

View File

@@ -2,7 +2,9 @@
# See license.txt # See license.txt
import frappe 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 from erpnext.tests.utils import ERPNextTestSuite
@@ -55,6 +57,52 @@ class TestPaymentTermsTemplate(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, template.insert) 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): def test_duplicate_terms(self):
template = frappe.get_doc( template = frappe.get_doc(
{ {

View File

@@ -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) { frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) {
return { return {
filters: { filters: {

View File

@@ -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.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.asset import is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account 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.accounts_controller import validate_account_head
from erpnext.controllers.buying_controller import BuyingController from erpnext.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
@@ -279,7 +278,9 @@ class PurchaseInvoice(BuyingController):
self.check_conversion_rate() self.check_conversion_rate()
self.validate_credit_to_acc() self.validate_credit_to_acc()
self.clear_unallocated_advances("Purchase Invoice Advance", "advances") 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_with_previous_doc()
self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("uom", "qty")
self.validate_uom_is_integer("stock_uom", "stock_qty") self.validate_uom_is_integer("stock_uom", "stock_qty")
@@ -287,6 +288,8 @@ class PurchaseInvoice(BuyingController):
self.validate_expense_account() self.validate_expense_account()
self.set_against_expense_account() self.set_against_expense_account()
self.validate_write_off_account() self.validate_write_off_account()
self.validate_write_off_cost_center()
from erpnext.accounts.services.billing_validation import BillingValidationService from erpnext.accounts.services.billing_validation import BillingValidationService
BillingValidationService(self).validate_multiple_billing("Purchase Receipt", "pr_detail", "amount") BillingValidationService(self).validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
@@ -386,14 +389,6 @@ class PurchaseInvoice(BuyingController):
self.party_account_currency = account.account_currency 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): def validate_with_previous_doc(self):
super().validate_with_previous_doc( super().validate_with_previous_doc(
{ {
@@ -658,6 +653,27 @@ class PurchaseInvoice(BuyingController):
if self.write_off_amount and not self.write_off_account: if self.write_off_amount and not self.write_off_account:
throw(_("Please enter 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): def check_prev_docstatus(self):
for d in self.get("items"): for d in self.get("items"):
if d.purchase_order: if d.purchase_order:
@@ -738,6 +754,7 @@ class PurchaseInvoice(BuyingController):
def validate_for_repost(self): def validate_for_repost(self):
self.validate_write_off_account() self.validate_write_off_account()
self.validate_write_off_cost_center()
self.validate_expense_account() self.validate_expense_account()
validate_docs_for_voucher_types(["Purchase Invoice"]) validate_docs_for_voucher_types(["Purchase Invoice"])
validate_docs_for_deferred_accounting([], [self.name]) validate_docs_for_deferred_accounting([], [self.name])
@@ -848,7 +865,9 @@ class PurchaseInvoice(BuyingController):
if update_outstanding == "No": if update_outstanding == "No":
update_voucher_outstanding( update_voucher_outstanding(
voucher_type=self.doctype, 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, account=self.credit_to,
party_type="Supplier", party_type="Supplier",
party=self.supplier, party=self.supplier,
@@ -876,7 +895,9 @@ class PurchaseInvoice(BuyingController):
super().on_cancel() super().on_cancel()
PurchaseTaxWithholding(self).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: if self.is_return and not self.update_billed_amount_in_purchase_order:
# NOTE status updating bypassed for is_return # NOTE status updating bypassed for is_return

View File

@@ -720,6 +720,9 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
def make_payment_gl_entries(self, gl_entries): def make_payment_gl_entries(self, gl_entries):
doc = self.doc doc = self.doc
if cint(doc.is_paid) and doc.cash_bank_account and doc.paid_amount: 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) bank_account_currency = get_account_currency(doc.cash_bank_account)
gl_entries.append( gl_entries.append(
@@ -734,9 +737,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
if doc.party_account_currency == doc.company_currency if doc.party_account_currency == doc.company_currency
else doc.paid_amount, else doc.paid_amount,
"debit_in_transaction_currency": doc.paid_amount, "debit_in_transaction_currency": doc.paid_amount,
"against_voucher": doc.return_against "against_voucher": against_voucher,
if cint(doc.is_return) and doc.return_against
else doc.name,
"against_voucher_type": doc.doctype, "against_voucher_type": doc.doctype,
"cost_center": doc.cost_center, "cost_center": doc.cost_center,
"project": doc.project, "project": doc.project,

View File

@@ -9,7 +9,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values from frappe.model.utils import get_fetch_values
from frappe.utils import flt, get_link_to_form, getdate 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() @frappe.whitelist()
@@ -360,7 +360,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
"doctype": target_doctype, "doctype": target_doctype,
"postprocess": update_details, "postprocess": update_details,
"set_target_warehouse": "set_from_warehouse", "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, doctype + " Item": item_field_map,
}, },
@@ -520,7 +520,7 @@ def update_taxes(
master_doctype=None, master_doctype=None,
): ):
# Update Party Details # Update Party Details
party_details = get_party_details( party_details = _get_party_details(
party=party, party=party,
party_type=party_type, party_type=party_type,
company=company, company=company,

View File

@@ -58,28 +58,29 @@ class LoyaltyService:
def delete_loyalty_point_entry(self) -> None: def delete_loyalty_point_entry(self) -> None:
doc = self.doc doc = self.doc
lp_entry = frappe.db.sql( lp_entry = frappe.db.get_all(
"select name from `tabLoyalty Point Entry` where invoice=%s", (doc.name), as_dict=1 "Loyalty Point Entry", filters={"invoice": doc.name, "loyalty_points": (">", 0)}, fields=["name"]
) )
if not lp_entry: if not lp_entry:
return return
against_lp_entry = frappe.db.sql( against_lp_entry = frappe.db.get_all(
"""select name, invoice from `tabLoyalty Point Entry` "Loyalty Point Entry",
where redeem_against=%s""", filters={"redeem_against": lp_entry[0].name},
(lp_entry[0].name), fields=["name", "invoice"],
as_dict=1,
) )
if against_lp_entry: if against_lp_entry:
invoice_list = ", ".join([d.invoice for d in against_lp_entry]) invoice_list = ", ".join([d.invoice for d in against_lp_entry])
frappe.throw( 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) ).format(doc.doctype, doc.doctype, invoice_list)
) )
else: 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() self._set_loyalty_program_tier()
def apply_loyalty_points(self) -> None: def apply_loyalty_points(self) -> None:

View File

@@ -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: def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
data = frappe.db.sql( ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
""" ModeOfPayment = frappe.qb.DocType("Mode of Payment")
select
mpa.default_account, mpa.parent as mop, mp.type as type query = (
from frappe.qb.from_(ModeOfPaymentAccount)
`tabMode of Payment Account` mpa,`tabMode of Payment` mp .join(ModeOfPayment)
where .on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
mpa.parent = mp.name and .select(
mpa.company = %s and ModeOfPaymentAccount.default_account,
mp.enabled = 1 and ModeOfPaymentAccount.parent.as_("mop"),
mp.name in %s ModeOfPayment.type.as_("type"),
group by )
mp.name .where(ModeOfPaymentAccount.company == company)
""", .where(ModeOfPayment.enabled == 1)
(company, mode_of_payments), .where(ModeOfPayment.name.isin(mode_of_payments))
as_dict=1, .groupby(ModeOfPayment.name)
) )
data = query.run(as_dict=1)
return {row.get("mop"): row for row in data} return {row.get("mop"): row for row in data}
def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list: def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
return frappe.db.sql( ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
""" ModeOfPayment = frappe.qb.DocType("Mode of Payment")
select mpa.default_account, mpa.parent, mp.type as type
from `tabMode of Payment Account` mpa,`tabMode of Payment` mp query = (
where mpa.parent = mp.name and mpa.company = %s and mp.enabled = 1 and mp.name = %s""", frappe.qb.from_(ModeOfPayment)
(company, mode_of_payment), .join(ModeOfPaymentAccount)
as_dict=1, .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)

View File

@@ -383,6 +383,262 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(si.net_total, 3859.65) self.assertEqual(si.net_total, 3859.65)
self.assertEqual(si.grand_total, 4900.00) 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): def test_sales_invoice_discount_amount(self):
si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3]) si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3])
si.discount_amount = 104.94 si.discount_amount = 104.94
@@ -2662,6 +2918,34 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(target_doc.company, "_Test Company 1") self.assertEqual(target_doc.company, "_Test Company 1")
self.assertEqual(target_doc.supplier, "_Test Internal Supplier") 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): def test_inter_company_transaction_without_default_warehouse(self):
"Check mapping (expense account) of inter company SI to PI in absence of default warehouse." "Check mapping (expense account) of inter company SI to PI in absence of default warehouse."
# setup # setup

View File

@@ -49,6 +49,25 @@ SALES_TRANSACTION_TYPES = {
} }
TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | 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): class DuplicatePartyAccountError(frappe.ValidationError):
pass pass
@@ -65,7 +84,6 @@ def get_party_details(
price_list: str | None = None, price_list: str | None = None,
currency: str | None = None, currency: str | None = None,
doctype: str | None = None, doctype: str | None = None,
ignore_permissions: bool | None = False,
fetch_payment_terms_template: bool = True, fetch_payment_terms_template: bool = True,
party_address: str | None = None, party_address: str | None = None,
company_address: str | None = None, company_address: str | None = None,
@@ -75,8 +93,6 @@ def get_party_details(
): ):
if not party: if not party:
return frappe._dict() 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( return _get_party_details(
party, party,
account, account,
@@ -87,7 +103,7 @@ def get_party_details(
price_list, price_list,
currency, currency,
doctype, doctype,
ignore_permissions, False,
fetch_payment_terms_template, fetch_payment_terms_template,
party_address, party_address,
company_address, company_address,

View File

@@ -94,6 +94,9 @@ def get_data(filters):
def get_sales_details(filters): def get_sales_details(filters):
item_details_map = {} 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" date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date"
sales_data = frappe.db.sql( sales_data = frappe.db.sql(

View File

@@ -177,7 +177,7 @@ class PaymentScheduleService:
payment_schedule["credit_days"] = cint(schedule.credit_days) payment_schedule["credit_days"] = cint(schedule.credit_days)
payment_schedule["credit_months"] = cint(schedule.credit_months) 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_date"] = get_discount_date(schedule, posting_date)
payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on
payment_schedule["discount_validity"] = cint(schedule.discount_validity) payment_schedule["discount_validity"] = cint(schedule.discount_validity)
@@ -220,6 +220,8 @@ class PaymentScheduleService:
return return
for d in doc.get("payment_schedule"): 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") d.validate_from_to_dates("discount_date", "due_date")
if doc.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( if doc.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate(
doc.transaction_date doc.transaction_date
@@ -353,12 +355,11 @@ def get_payment_term_details(
term_details.outstanding = term_details.payment_amount term_details.outstanding = term_details.payment_amount
term_details.base_outstanding = term_details.base_payment_amount term_details.base_outstanding = term_details.base_payment_amount
if bill_date: has_discount = flt(term.get("discount"))
term_details.due_date = get_due_date(term, bill_date) date = bill_date or posting_date
term_details.discount_date = get_discount_date(term, bill_date) if date:
elif posting_date: term_details.due_date = get_due_date(term, date)
term_details.due_date = get_due_date(term, posting_date) term_details.discount_date = get_discount_date(term, date) if has_discount else None
term_details.discount_date = get_discount_date(term, posting_date)
if posting_date and getdate(term_details.due_date) < getdate(posting_date): if posting_date and getdate(term_details.due_date) < getdate(posting_date):
term_details.due_date = posting_date term_details.due_date = posting_date

View File

@@ -31,7 +31,8 @@ class BulkTransactionLog(Document):
log_detail = qb.DocType("Bulk Transaction Log Detail") log_detail = qb.DocType("Bulk Transaction Log Detail")
has_records = frappe.db.sql( 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] )[0][0]
if not has_records: if not has_records:
raise frappe.DoesNotExistError raise frappe.DoesNotExistError

View File

@@ -10,6 +10,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt, get_link_to_form from frappe.utils import flt, get_link_to_form
from erpnext.accounts.party import get_party_account 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.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_item_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): def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0 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): 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) received_qty = flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) qty = flt(obj.qty)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) pending_qty = qty - received_qty
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) 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): def select_item(d):
filtered_items = args.get("filtered_children", []) filtered_items = args.get("filtered_children", [])
@@ -72,7 +85,9 @@ def make_purchase_receipt(
}, },
"postprocess": update_item, "postprocess": update_item,
"condition": lambda doc: ( "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 doc.delivered_by_supplier != 1
and select_item(doc), and select_item(doc),

View File

@@ -351,9 +351,10 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
if (doc.status != "Closed") { if (doc.status != "Closed") {
if (doc.status != "On Hold") { if (doc.status != "On Hold") {
if ( if (
doc.items (doc.items
.filter((item) => !item.delivered_by_supplier) .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 allow_receipt
) { ) {
this.frm.add_custom_button( this.frm.add_custom_button(

View File

@@ -16,8 +16,9 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
validate_inter_company_party, validate_inter_company_party,
) )
from erpnext.accounts.party import get_party_account_currency 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.buying_controller import BuyingController
from erpnext.controllers.status_updater import get_allowance_for
from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order, validate_against_blanket_order,
) )
@@ -187,6 +188,7 @@ class PurchaseOrder(BuyingController):
def onload(self): def onload(self):
self.set_onload("can_update_items", self.can_update_items()) 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): def before_validate(self):
self.set_has_unit_price_items() self.set_has_unit_price_items()
@@ -203,7 +205,7 @@ class PurchaseOrder(BuyingController):
self.validate_supplier() self.validate_supplier()
self.validate_schedule_date() self.validate_schedule_date()
validate_for_items(self) 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("uom", "qty")
self.validate_uom_is_integer("stock_uom", "stock_qty") self.validate_uom_is_integer("stock_uom", "stock_qty")
@@ -382,18 +384,6 @@ class PurchaseOrder(BuyingController):
d.base_rate d.base_rate
) = d.price_list_rate = d.rate = d.last_purchase_rate = item_last_purchase_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): def update_ordered_qty(self, po_item_rows=None):
"""update requested qty (before ordered_qty is updated)""" """update requested qty (before ordered_qty is updated)"""
item_wh_list = [] item_wh_list = []
@@ -475,7 +465,7 @@ class PurchaseOrder(BuyingController):
self.set_received_qty_to_zero_for_drop_ship_items() self.set_received_qty_to_zero_for_drop_ship_items()
self.update_receiving_percentage() 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") self.db_set("status", "Cancelled")
@@ -660,6 +650,19 @@ class PurchaseOrder(BuyingController):
return result 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): 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 Updates ordered_qty in linked SO when item rows are removed using Update Items

View File

@@ -98,6 +98,60 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.load_from_db() po.load_from_db()
self.assertEqual(po.get("items")[0].received_qty, 4) 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): def test_ordered_qty_against_pi_with_update_stock(self):
existing_ordered_qty = get_ordered_qty() existing_ordered_qty = get_ordered_qty()
po = create_purchase_order() po = create_purchase_order()

View File

@@ -8,7 +8,7 @@ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc 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 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): def postprocess(source, target_doc):
if for_supplier: if for_supplier:
target_doc.supplier = 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( target_doc.currency = args.currency or get_party_account_currency(
"Supplier", for_supplier, source.company "Supplier", for_supplier, source.company
) )

View File

@@ -118,12 +118,12 @@ class TestSupplier(ERPNextTestSuite):
self.assertEqual(supplier.country, "Greece") self.assertEqual(supplier.country, "Greece")
def test_party_details_tax_category(self): 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") frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing")
# Tax Category without Address # 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") self.assertEqual(details.tax_category, "_Test Tax Category 1")
address = frappe.get_doc( address = frappe.get_doc(
@@ -138,7 +138,7 @@ class TestSupplier(ERPNextTestSuite):
).insert() ).insert()
# Tax Category with Address # 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") self.assertEqual(details.tax_category, "_Test Tax Category 2")
# Rollback # Rollback

View File

@@ -113,7 +113,14 @@ def check_on_hold_or_closed_status(doctype, docname) -> None:
status = frappe.db.get_value(doctype, docname, "status") status = frappe.db.get_value(doctype, docname, "status")
if status in ("Closed", "On Hold"): 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() @frappe.whitelist()

View File

@@ -156,7 +156,7 @@ class AccountsController(TransactionBase):
if not get_meta(self.doctype).has_field("outstanding_amount"): if not get_meta(self.doctype).has_field("outstanding_amount"):
return 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( against_voucher_outstanding = frappe.get_value(
self.doctype, self.return_against, "outstanding_amount" self.doctype, self.return_against, "outstanding_amount"
) )

View File

@@ -13,7 +13,7 @@ from frappe.utils.data import nowtime
import erpnext import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions 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.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.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return 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 # set contact and address details for supplier, if they are not mentioned
if getattr(self, "supplier", None): if getattr(self, "supplier", None):
self.update_if_missing( self.update_if_missing(
get_party_details( _get_party_details(
self.supplier, self.supplier,
party_type="Supplier", party_type="Supplier",
doctype=self.doctype, 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): def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
self.update_ordered_and_reserved_qty() self.update_ordered_and_reserved_qty()

View File

@@ -469,11 +469,9 @@ class SellingController(StockController):
return so_qty, so_warehouse return so_qty, so_warehouse
def check_sales_order_on_hold_or_close(self, ref_fieldname): def check_sales_order_on_hold_or_close(self, ref_fieldname):
for d in self.get("items"): if self.is_return:
if d.get(ref_fieldname): return
status = frappe.db.get_value("Sales Order", d.get(ref_fieldname), "status") self.check_for_on_hold_or_closed_status("Sales Order", ref_fieldname)
if status in ("Closed", "On Hold") and not self.is_return:
frappe.throw(_("Sales Order {0} is {1}").format(d.get(ref_fieldname), status))
def update_reserved_qty(self): def update_reserved_qty(self):
so_map = {} so_map = {}

View File

@@ -524,9 +524,9 @@ class StatusUpdater(Document):
for args in self.status_updater: for args in self.status_updater:
# condition to include current record (if submit or no if cancel) # condition to include current record (if submit or no if cancel)
if self.docstatus == 1: if self.docstatus == 1:
args["cond"] = " or parent='%s'" % self.name.replace('"', '"') args["cond"] = " or parent=%s" % frappe.db.escape(self.name)
else: 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) self._update_children(args, update_modified)
@@ -556,9 +556,10 @@ class StatusUpdater(Document):
args["second_source_condition"] = frappe.db.sql( args["second_source_condition"] = frappe.db.sql(
""" select ifnull((select sum({second_source_field}) """ select ifnull((select sum({second_source_field})
from `tab{second_source_dt}` 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) 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] )[0][0]
if args["detail_id"]: if args["detail_id"]:
@@ -569,9 +570,10 @@ class StatusUpdater(Document):
frappe.db.sql( frappe.db.sql(
""" """
(select ifnull(sum({source_field}), 0) (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}) and (docstatus=1 {cond}) {extra_cond})
""".format(**args) """.format(**args),
{"detail_id": args["detail_id"]},
)[0][0] )[0][0]
or 0.0 or 0.0
) )
@@ -582,7 +584,8 @@ class StatusUpdater(Document):
frappe.db.sql( frappe.db.sql(
"""update `tab{target_dt}` """update `tab{target_dt}`
set {target_field} = {source_dt_value} {update_modified} 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 @staticmethod

View File

@@ -1803,6 +1803,43 @@ class StockController(AccountsController):
qty -= working_qty 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("<br>".join(errors), frappe.InvalidStatusError)
@frappe.whitelist() @frappe.whitelist()
def show_accounting_ledger_preview(company: str, doctype: str, docname: str): def show_accounting_ledger_preview(company: str, doctype: str, docname: str):

View File

@@ -304,6 +304,7 @@ class calculate_taxes_and_totals:
return return
for item in self.doc.items: for item in self.doc.items:
item._unrounded_net_amount = None
item_tax_map = self._load_item_tax_rate(item.item_tax_rate) item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
cumulated_tax_fraction = 0 cumulated_tax_fraction = 0
total_inclusive_tax_amount_per_qty = 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 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.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
item.discount_percentage = flt( item.discount_percentage = flt(
item.discount_percentage, item.precision("discount_percentage") item.discount_percentage, item.precision("discount_percentage")
@@ -541,7 +543,9 @@ class calculate_taxes_and_totals:
actual_breakup = tax._total_tax_breakup actual_breakup = tax._total_tax_breakup
diff = flt(expected_amount - actual_breakup, 5) diff = flt(expected_amount - actual_breakup, 5)
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 = self.doc._item_wise_tax_details[last_idx]
detail_row["amount"] = flt(detail_row["amount"] + diff, 5) detail_row["amount"] = flt(detail_row["amount"] + diff, 5)
@@ -600,7 +604,16 @@ class calculate_taxes_and_totals:
elif tax.charge_type == "On Net Total": elif tax.charge_type == "On Net Total":
if tax.account_head in item_tax_map: if tax.account_head in item_tax_map:
current_net_amount = item.net_amount 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": 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_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 current_tax_amount = (tax_rate / 100.0) * current_net_amount

View File

@@ -7,7 +7,7 @@ import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.modules.utils import get_module_app 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 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): def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_page_length):
data = frappe.db.sql( data = frappe.db.sql(
"""select distinct parent as name, supplier from `tab{doctype}` f"""select distinct parent as name, supplier from `tab{parties_doctype}`
where supplier = '{supplier}' and docstatus=1 order by creation desc limit {start}, {len}""".format( where supplier = %(supplier)s and docstatus=1 order by creation desc limit %(start)s, %(len)s""",
doctype=parties_doctype, supplier=parties[0], start=limit_start, len=limit_page_length {
), "supplier": parties[0],
"start": cint(limit_start),
"len": cint(limit_page_length),
},
as_dict=1, as_dict=1,
) )

View File

@@ -24,6 +24,7 @@ from frappe.utils import (
) )
from pypika import functions as fn from pypika import functions as fn
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.manufacturing.doctype.bom.bom import ( from erpnext.manufacturing.doctype.bom.bom import (
get_bom_item_rate, get_bom_item_rate,
get_bom_items_as_dict, get_bom_items_as_dict,
@@ -438,7 +439,7 @@ class WorkOrder(Document):
production_item = main_item_code production_item = main_item_code
if self.sales_order: 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") SalesOrder = frappe.qb.DocType("Sales Order")
SalesOrderItem = frappe.qb.DocType("Sales Order Item") SalesOrderItem = frappe.qb.DocType("Sales Order Item")
@@ -494,11 +495,6 @@ class WorkOrder(Document):
else: else:
frappe.throw(_("Sales Order {0} is not valid").format(self.sales_order)) 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): def set_default_warehouse(self):
if not self.wip_warehouse and not self.skip_transfer: if not self.wip_warehouse and not self.skip_transfer:
self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse") self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse")

View File

@@ -258,6 +258,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
if (has_inclusive_tax == false) return; if (has_inclusive_tax == false) return;
$.each(this.frm.doc.items || [], function (n, item) { $.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 item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
var cumulated_tax_fraction = 0.0; var cumulated_tax_fraction = 0.0;
var total_inclusive_tax_amount_per_qty = 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) (total_inclusive_tax_amount_per_qty || cumulated_tax_fraction)
) { ) {
var amount = flt(item.amount) - total_inclusive_tax_amount_per_qty; 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; 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"]); 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) { if (tax.account_head in item_tax_map) {
current_net_amount = item.net_amount; 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") { } 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_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item;
current_tax_amount = current_tax_amount =

View File

@@ -55,7 +55,7 @@ class TestCustomer(ERPNextTestSuite):
doc.delete() doc.delete()
def test_party_details(self): def test_party_details(self):
from erpnext.accounts.party import get_party_details from erpnext.accounts.party import _get_party_details
to_check = { to_check = {
"selling_price_list": None, "selling_price_list": None,
@@ -77,7 +77,7 @@ class TestCustomer(ERPNextTestSuite):
"Contact", "_Test Contact for _Test Customer-_Test Customer", "is_primary_contact", 1 "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(): for key, value in to_check.items():
val = details.get(key) val = details.get(key)
@@ -87,10 +87,10 @@ class TestCustomer(ERPNextTestSuite):
self.assertEqual(value, val) self.assertEqual(value, val)
def test_party_details_tax_category(self): 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 # 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") self.assertEqual(details.tax_category, "_Test Tax Category 1")
frappe.get_doc( frappe.get_doc(
@@ -122,13 +122,13 @@ class TestCustomer(ERPNextTestSuite):
# Tax Category from Billing Address # Tax Category from Billing Address
settings.determine_address_tax_category_from = "Billing Address" settings.determine_address_tax_category_from = "Billing Address"
settings.save() 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") self.assertEqual(details.tax_category, "_Test Tax Category 2")
# Tax Category from Shipping Address # Tax Category from Shipping Address
settings.determine_address_tax_category_from = "Shipping Address" settings.determine_address_tax_category_from = "Shipping Address"
settings.save() 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") self.assertEqual(details.tax_category, "_Test Tax Category 3")
# Rollback # Rollback

View File

@@ -12,7 +12,7 @@ from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import Sum from frappe.query_builder.functions import Sum
from frappe.utils import add_days, cint, flt, nowdate, strip_html 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 ( from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_items_for_material_requests, get_items_for_material_requests,
get_sales_orders, get_sales_orders,
@@ -669,7 +669,6 @@ def make_purchase_order(
target.shipping_rule = "" target.shipping_rule = ""
target.tc_name = "" target.tc_name = ""
target.terms = "" target.terms = ""
target.payment_terms_template = ""
target.payment_schedule = [] target.payment_schedule = []
default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") default_price_list = frappe.get_value("Supplier", supplier, "default_price_list")
@@ -736,16 +735,7 @@ def make_purchase_order(
{ {
"Sales Order": { "Sales Order": {
"doctype": "Purchase Order", "doctype": "Purchase Order",
"field_no_map": [ "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"taxes_and_charges",
"shipping_address",
"dispatch_address",
],
"validation": {"docstatus": ["=", 1]}, "validation": {"docstatus": ["=", 1]},
}, },
"Sales Order Item": { "Sales Order Item": {

View File

@@ -24,6 +24,7 @@ from erpnext.selling.doctype.sales_order.mapper import (
make_delivery_note, make_delivery_note,
make_material_request, make_material_request,
make_production_plan, make_production_plan,
make_purchase_order,
make_raw_material_request, make_raw_material_request,
make_sales_invoice, make_sales_invoice,
make_work_orders, make_work_orders,
@@ -1165,9 +1166,6 @@ class TestSalesOrder(ERPNextTestSuite):
def test_drop_shipping(self): def test_drop_shipping(self):
from erpnext.buying.doctype.purchase_order.purchase_order import update_status 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 from erpnext.selling.doctype.sales_order.sales_order import update_status as so_update_status
# make items # make items
@@ -1261,9 +1259,6 @@ class TestSalesOrder(ERPNextTestSuite):
so.cancel() so.cancel()
def test_drop_shipping_partial_order(self): 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 from erpnext.selling.doctype.sales_order.sales_order import update_status as so_update_status
# make items # make items
@@ -1321,10 +1316,6 @@ class TestSalesOrder(ERPNextTestSuite):
def test_drop_shipping_full_for_default_suppliers(self): def test_drop_shipping_full_for_default_suppliers(self):
"""Test if multiple POs are generated in one go against different default suppliers.""" """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"): 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}) 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 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. 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}) 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 1", {"is_stock_item": 1})
make_item("_Test Bundle Item 2", {"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 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}) 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 1", {"is_stock_item": 1})
make_item("_Test Bundle Item 2", {"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") self.assertEqual(so.status, "To Deliver and Bill")
def test_item_tax_transfer_from_sales_to_purchase(self): 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 = frappe.new_doc("Item Tax Template")
item_tax.title = "Test Item Tax Template" item_tax.title = "Test Item Tax Template"
item_tax.company = "_Test Company" item_tax.company = "_Test Company"
@@ -2697,6 +2682,33 @@ class TestSalesOrder(ERPNextTestSuite):
po.submit() po.submit()
self.assertEqual(po.taxes[0].tax_amount, 2) 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): def test_pending_quantity_after_update_item_during_invoice_creation(self):
so = make_sales_order(qty=30, rate=100) so = make_sales_order(qty=30, rate=100)

View File

@@ -7,7 +7,7 @@ from frappe import _, qb
from frappe.query_builder import Criterion from frappe.query_builder import Criterion
from erpnext import get_default_company 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): def execute(filters=None):
@@ -125,7 +125,7 @@ def get_data(filters=None):
def get_customer_details(filters): 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( customer_details.update(
{"company": get_default_company(), "price_list": customer_details.get("selling_price_list")} {"company": get_default_company(), "price_list": customer_details.get("selling_price_list")}
) )

View File

@@ -14,6 +14,9 @@ def execute(filters=None):
days_since_last_order = filters.get("days_since_last_order") days_since_last_order = filters.get("days_since_last_order")
doctype = filters.get("doctype") 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: if cint(days_since_last_order) <= 0:
frappe.throw(_("'Days Since Last Order' must be greater than or equal to zero")) frappe.throw(_("'Days Since Last Order' must be greater than or equal to zero"))

View File

@@ -497,14 +497,16 @@ class Analytics:
break break
def get_groups(self): def get_groups(self):
if self.filters.tree_type == "Territory": parent_field_map = {
parent = "parent_territory" "Territory": "parent_territory",
if self.filters.tree_type == "Customer Group": "Customer Group": "parent_customer_group",
parent = "parent_customer_group" "Item Group": "parent_item_group",
if self.filters.tree_type == "Item Group": "Supplier Group": "parent_supplier_group",
parent = "parent_item_group" }
if self.filters.tree_type == "Supplier Group": if self.filters.tree_type not in parent_field_map:
parent = "parent_supplier_group" frappe.throw(_("Invalid Tree Type {0}").format(self.filters.tree_type))
parent = parent_field_map[self.filters.tree_type]
self.depth_map = frappe._dict() self.depth_map = frappe._dict()
@@ -523,6 +525,9 @@ class Analytics:
def get_teams(self): def get_teams(self):
self.depth_map = frappe._dict() 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( self.group_entries = frappe.db.sql(
f""" select * from (select "Order Types" as name, 0 as lft, 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 2 as rgt, '' as parent union select distinct order_type as name, 1 as lft, 1 as rgt, "Order Types" as parent

View File

@@ -120,7 +120,9 @@ class AuthorizationControl(TransactionBase):
if val == 1: if val == 1:
add_cond += " and system_user = {}".format(frappe.db.escape(session["user"])) add_cond += " and system_user = {}".format(frappe.db.escape(session["user"]))
elif val == 2: 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: else:
add_cond += " and ifnull(system_user,'') = '' and ifnull(system_role,'') = ''" add_cond += " and ifnull(system_user,'') = '' and ifnull(system_role,'') = ''"
@@ -206,8 +208,8 @@ class AuthorizationControl(TransactionBase):
and docstatus != 2 and docstatus != 2
""".format( """.format(
"%s", "%s",
"'" + "','".join(frappe.get_roles()) + "'", ", ".join(frappe.db.escape(r) for r in frappe.get_roles()),
"'" + "','".join(final_based_on) + "'", ", ".join(frappe.db.escape(b) for b in final_based_on),
"%s", "%s",
), ),
(doctype_name, company), (doctype_name, company),

View File

@@ -13,7 +13,7 @@ from frappe.query_builder import DocType
from frappe.query_builder.functions import Abs, Sum from frappe.query_builder.functions import Abs, Sum
from frappe.utils import flt 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 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: {
"doctype": target_doctype, "doctype": target_doctype,
"postprocess": update_details, "postprocess": update_details,
"field_no_map": ["taxes_and_charges", "set_warehouse"], "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"],
"field_map": {"shipping_address_name": "shipping_address"},
}, },
doctype + " Item": { doctype + " Item": {
"doctype": target_doctype + " Item", "doctype": target_doctype + " Item",

View File

@@ -250,7 +250,7 @@ class MaterialRequest(BuyingController):
def check_modified_date(self): def check_modified_date(self):
mod_db = frappe.db.sql("""select modified from `tabMaterial Request` where name = %s""", self.name) 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]: if date_diff and date_diff[0][0]:
frappe.throw(_("{0} {1} has been modified. Please refresh.").format(_(self.doctype), self.name)) frappe.throw(_("{0} {1} has been modified. Please refresh.").format(_(self.doctype), self.name))

View File

@@ -13,7 +13,6 @@ from pypika import functions as fn
import erpnext import erpnext
from erpnext.accounts.utils import get_account_currency from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled 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.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation 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_cwip_accounts()
self.validate_provisional_expense_account() 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()): if getdate(self.posting_date) > getdate(nowdate()):
throw(_("Posting Date cannot be future date")) 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"]) po_qty, po_warehouse = frappe.db.get_value("Purchase Order Item", po_detail, ["qty", "warehouse"])
return po_qty, po_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 # on submit
def on_submit(self): def on_submit(self):
super().on_submit() super().on_submit()
@@ -446,7 +437,7 @@ class PurchaseReceipt(BuyingController):
def on_cancel(self): def on_cancel(self):
super().on_cancel() 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 # Check if Purchase Invoice has been submitted against current Purchase Order
submitted = frappe.db.sql( submitted = frappe.db.sql(
"""select t1.name """select t1.name

View File

@@ -1051,6 +1051,40 @@ class TestPurchaseReceipt(ERPNextTestSuite):
pr.cancel() 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): 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.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note

View File

@@ -284,7 +284,7 @@ def set_stock_balance_as_per_serial_no(
if not posting_time: if not posting_time:
posting_time = nowtime() 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( bin = frappe.db.sql(
"""select bin.item_code, bin.warehouse, bin.actual_qty, item.stock_uom """select bin.item_code, bin.warehouse, bin.actual_qty, item.stock_uom

View File

@@ -10,7 +10,6 @@ from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
import erpnext import erpnext
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_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() self.create_raw_materials_supplied_or_received()
def validate_closed_subcontracting_order(self): def validate_closed_subcontracting_order(self):
for item in self.items: self.check_for_on_hold_or_closed_status("Subcontracting Order", "subcontracting_order")
if item.subcontracting_order:
check_on_hold_or_closed_status("Subcontracting Order", item.subcontracting_order)
def update_job_card(self): def update_job_card(self):
for row in self.get("items"): for row in self.get("items"):