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):
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

View File

@@ -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(

View File

@@ -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(
{

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) {
return {
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.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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

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:
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)

View File

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

View File

@@ -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,

View File

@@ -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(

View File

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

View File

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

View File

@@ -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),

View File

@@ -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(

View File

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

View File

@@ -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()

View File

@@ -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
)

View File

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

View File

@@ -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()

View File

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

View File

@@ -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()

View File

@@ -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 = {}

View File

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

View File

@@ -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("<br>".join(errors), frappe.InvalidStatusError)
@frappe.whitelist()
def show_accounting_ledger_preview(company: str, doctype: str, docname: str):

View File

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

View File

@@ -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,
)

View File

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

View File

@@ -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 =

View File

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

View File

@@ -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": {

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -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))

View File

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

View File

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

View File

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

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
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"):