mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-12 06:01:46 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")}
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user