Merge branch 'develop' into party-import-tool-integrated-with-data-import-tool

This commit is contained in:
Sumit Jain
2026-08-25 13:46:04 +05:30
committed by GitHub
318 changed files with 225873 additions and 145928 deletions

View File

@@ -22,6 +22,6 @@ jobs:
pull-requests: write
steps:
- uses: alyf-de/po-review-action@v1.1.0
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
with:
hidden-po-files: eo.po

View File

@@ -91,3 +91,5 @@ pull_request_rules:
commit_message_format:
title: pr-title
body: pr-body
merge_queue:
queue_controls_comment: false

View File

@@ -24,10 +24,10 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.17.0",
"frappe-react-sdk": "^1.17.1",
"fuse.js": "^7.3.0",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.2",
"jotai-family": "^1.1.0",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.6.1",
@@ -39,7 +39,7 @@
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^8.1.0",
"react-router": "^8.3.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
@@ -48,18 +48,18 @@
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.16"
"vite": "^8.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.5",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.62.1"
"typescript-eslint": "^8.67.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -727,6 +727,7 @@ def get_company_default_account_fields():
"stock_delivered_but_not_billed": "Stock Delivered But Not Billed Account",
"stock_adjustment_account": "Stock Adjustment Account",
"write_off_account": "Write Off Account",
"bank_charges_account": "Bank Charges Account",
"default_discount_account": "Default Payment Discount Account",
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
"exchange_gain_loss_account": "Exchange Gain / Loss Account",

View File

@@ -95,13 +95,14 @@
"column_break_25",
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"receivable_payable_remarks_length",
"disable_include_dimensions",
"column_break_lvjk",
"general_ledger_remarks_length",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"default_ageing_range",
"column_break_ntmi",
"receivable_payable_remarks_length",
"legacy_section",
"ignore_is_opening_check_for_reporting",
"tab_break_dpet",
@@ -199,10 +200,12 @@
},
{
"default": "1",
"description": "System will use the latest saved Currency Exchange rate on or before the transaction date, however old it is. <br>\nUncheck to ignore rates older than Stale Days and fetch a fresh rate from the exchange rate provider instead.",
"fieldname": "allow_stale",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Allow Stale Exchange Rates"
"label": "Allow Stale Exchange Rates",
"show_description_on_click": 1
},
{
"default": "1",
@@ -278,10 +281,10 @@
},
{
"default": "0",
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
"description": "Enabling this option prevents the creation of a new Sales Invoice when the customer has an overdue limit set and their outstanding overdue amount exceeds that limit.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Restrict Customer Over Billing"
"label": "Prevent Sales Invoice when Customer is Overdue"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
@@ -475,7 +478,7 @@
{
"fieldname": "remarks_section",
"fieldtype": "Section Break",
"label": "Remarks Column Length"
"label": "General Ledger Report"
},
{
"default": "0",
@@ -549,7 +552,7 @@
{
"fieldname": "accounts_receivable_payable_tuning_section",
"fieldtype": "Section Break",
"label": "Accounts Receivable / Payable Tuning"
"label": "Accounts Receivable / Payable Report"
},
{
"fieldname": "legacy_section",
@@ -788,6 +791,12 @@
"fieldname": "book_stock_expense_gl_entries",
"fieldtype": "Check",
"label": "Book Stock Expense GL Entries"
},
{
"default": "0",
"fieldname": "disable_include_dimensions",
"fieldtype": "Check",
"label": "Disable \"Consider Accounting Dimension\" Filter"
}
],
"grid_page_length": 50,
@@ -796,7 +805,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-07-15 17:00:00.000000",
"modified": "2026-08-14 15:26:49.070889",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -72,6 +72,7 @@ class AccountsSettings(Document):
default_ageing_range: DF.Data | None
delete_linked_ledger_entries: DF.Check
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
disable_include_dimensions: DF.Check
enable_accounting_dimensions: DF.Check
enable_common_party_accounting: DF.Check
enable_discounts_and_margin: DF.Check
@@ -203,8 +204,8 @@ class AccountsSettings(Document):
if self.add_taxes_from_item_tax_template and self.add_taxes_from_taxes_and_charges_template:
frappe.throw(
_("You cannot enable both the settings '{0}' and '{1}'.").format(
frappe.bold(_(self.meta.get_label("add_taxes_from_item_tax_template"))),
frappe.bold(_(self.meta.get_label("add_taxes_from_taxes_and_charges_template"))),
frappe.bold(self.meta.get_translated_label("add_taxes_from_item_tax_template")),
frappe.bold(self.meta.get_translated_label("add_taxes_from_taxes_and_charges_template")),
),
title=_("Auto Tax Settings Error"),
)

View File

@@ -166,9 +166,10 @@ def get_transaction_reference(txn_data: dict) -> str:
).strip()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def convert_mt940_to_csv(data_import: str, mt940_file_path: str):
doc = frappe.get_doc("Bank Statement Import", data_import)
doc.check_permission("write")
_file_doc, content = get_file(mt940_file_path)
@@ -235,26 +236,30 @@ def convert_mt940_to_csv(data_import: str, mt940_file_path: str):
def get_preview_from_template(
data_import: str, import_file: str | None = None, google_sheets_url: str | None = None
):
return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template(
import_file, google_sheets_url
)
bsi = frappe.get_doc("Bank Statement Import", data_import)
bsi.check_permission()
return bsi.get_preview_from_template(import_file, google_sheets_url)
@frappe.whitelist()
def form_start_import(data_import: str):
job_id = frappe.get_doc("Bank Statement Import", data_import).start_import()
return job_id is not None
bsi = frappe.get_doc("Bank Statement Import", data_import)
bsi.check_permission("write")
return bsi.start_import()
@frappe.whitelist()
def download_errored_template(data_import_name: str):
data_import = frappe.get_doc("Bank Statement Import", data_import_name)
data_import.check_permission()
data_import.export_errored_rows()
@frappe.whitelist()
def download_import_log(data_import_name: str):
return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log()
bsi = frappe.get_doc("Bank Statement Import", data_import_name)
bsi.check_permission()
return bsi.download_import_log()
def is_mt940_format(content: str) -> bool:
@@ -393,6 +398,7 @@ def get_import_status(docname: str):
import_status = {}
data_import = frappe.get_doc("Bank Statement Import", docname)
data_import.check_permission()
import_status["status"] = data_import.status
logs = frappe.get_all(

View File

@@ -110,18 +110,6 @@ frappe.ui.form.on("Chart of Accounts Importer", {
args: {
company: frm.doc.company,
},
callback: function (r) {
if (r.message === false) {
frm.set_value("company", "");
frappe.throw(
__(
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
)
);
} else {
frm.trigger("refresh");
}
},
});
}
},

View File

@@ -70,7 +70,13 @@ def validate_company(company: str):
frappe.throw(msg, title=_("Wrong Company"))
if frappe.db.get_all("GL Entry", {"company": company}, "name", limit=1):
return False
frappe.throw(
_(
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
)
)
validate_user_perms(company)
@frappe.whitelist()
@@ -79,16 +85,22 @@ def import_coa(file_name: str, company: str):
# delete existing data for accounts
frappe.has_permission("Company", "write", company, throw=True)
unset_existing_data(company)
# create accounts
file_doc, extension = get_file(file_name)
validate_accounts(file_doc, extension)
if extension == "csv":
data = generate_data_from_csv(file_doc)
else:
data = generate_data_from_excel(file_doc, extension)
validate_columns(data)
validate_company(company)
unset_existing_data(company)
frappe.local.flags.ignore_root_company_validation = True
forest = build_forest(data)
create_charts(company, custom_chart=forest, from_coa_importer=True)
@@ -453,7 +465,6 @@ def get_mandatory_account_types():
def unset_existing_data(company):
# remove accounts data from company
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
linked = [{"fieldname": name} for name in fieldnames]
update_values = {d.get("fieldname"): "" for d in linked}
@@ -463,13 +474,30 @@ def unset_existing_data(company):
# remove accounts data from various doctypes
for doctype in [
"Account",
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
"Party Account",
"Mode of Payment Account",
"Tax Withholding Account",
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
]:
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
frappe.get_query(doctype, delete=True, filters={"company": company}).run()
def validate_user_perms(company):
# User Permission Check for Account Deletion
company_accounts_count = frappe.get_query(
"Account", fields=[{"COUNT": "name"}], filters={"company": company}
).run()[0][0]
company_accounts_user_has_access_to = frappe.get_query(
"Account", fields=[{"COUNT": "name"}], filters={"company": company}, ignore_permissions=False
).run()[0][0]
if company_accounts_count != company_accounts_user_has_access_to:
frappe.throw(
_("Accounts cannot be removed, as user doesn't have access to all the accounts of {0}").format(
frappe.bold(company)
)
)
def set_default_accounts(company):

View File

@@ -442,7 +442,9 @@ class AccountFilterValidator(Validator):
if not isinstance(field, str) or not isinstance(operator, str):
return "Field and operator must be strings"
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
display = (
field if advanced_filtering else self.account_meta.get_translated_label(field)
) or field
if field not in account_fields:
return f"Field '{display}' is not a valid Account field"

View File

@@ -134,7 +134,7 @@ class GLEntry(Document):
mandatory = ["account", "voucher_type", "voucher_no", "company"]
for k in mandatory:
if not self.get(k):
frappe.throw(_("{0} is required").format(_(self.meta.get_label(k))))
frappe.throw(_("{0} is required").format(self.meta.get_translated_label(k)))
if not self.is_cancelled and not (self.party_type and self.party):
account_type = frappe.get_cached_value("Account", self.account, "account_type")

View File

@@ -8,6 +8,6 @@ def get_data():
{"label": _("Pre Sales"), "items": ["Quotation", "Supplier Quotation"]},
{"label": _("Sales"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
{"label": _("Purchase"), "items": ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]},
{"label": _("Stock"), "items": ["Item Groups", "Item"]},
{"label": _("Stock"), "items": ["Item Group", "Item"]},
],
}

View File

@@ -249,7 +249,7 @@ Object.assign(erpnext.journal_entry, {
);
}
if (frm.doc.docstatus == 1) {
if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) {
frm.add_custom_button(
__("Reverse Journal Entry"),
() => erpnext.journal_entry.reverse_journal_entry(frm),
@@ -677,6 +677,6 @@ Object.assign(erpnext.journal_entry, {
} else {
erpnext.journal_entry.set_debit_credit_in_company_currency(frm, cdt, cdn);
}
frm.refresh_field("accounts");
frm.get_field("accounts").grid.refresh_row(cdn);
},
});

View File

@@ -180,7 +180,7 @@ class JournalEntry(AccountsController):
JournalTaxWithholding(self).on_validate()
if self.is_new() or not self.title:
if not self.title or (self.is_new() and self.amended_from):
self.title = self.get_title()
def validate_advance_accounts(self):

View File

@@ -222,6 +222,20 @@ def make_inter_company_journal_entry(name: str, voucher_type: str, company: str)
@frappe.whitelist()
def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Document | None = None) -> Document:
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
# `get_mapped_doc` checks this as well, but the guards below disclose which entry
# reverses which, so read access has to be settled before they run
if not frappe.has_permission("Journal Entry", doc=source_name):
frappe.throw(_("Not permitted"), frappe.PermissionError)
reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of")
if reversal_of:
frappe.throw(
_("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format(
get_link_to_form("Journal Entry", source_name),
get_link_to_form("Journal Entry", reversal_of),
)
)
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(

View File

@@ -301,6 +301,27 @@ class TestJournalEntry(ERPNextTestSuite):
self.check_gl_entries()
def test_disallow_reversal_of_a_reversal_journal_entry(self):
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
jv = make_journal_entry("_Test Bank - _TC", "Sales - _TC", 100, submit=True)
rjv = make_reverse_journal_entry(jv.name)
rjv.posting_date = nowdate()
rjv.submit()
self.assertRaisesRegex(
frappe.ValidationError,
"is already a Reverse Journal Entry",
make_reverse_journal_entry,
rjv.name,
)
# the guard must not disclose the reversal to a user who cannot read the entry
frappe.set_user("Guest")
self.addCleanup(frappe.set_user, "Administrator")
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
def test_disallow_change_in_account_currency_for_a_party(self):
# create jv in USD
jv = make_journal_entry("_Test Bank USD - _TC", "_Test Receivable USD - _TC", 100, save=False)

View File

@@ -476,6 +476,8 @@ frappe.ui.form.on("Payment Entry", {
return {
query: "erpnext.controllers.queries.employee_query",
};
} else if (["Customer", "Supplier"].includes(frm.doc.party_type)) {
return erpnext.queries.party(frm.doc);
} else if (frm.doc.party_type == "Shareholder") {
return {
filters: {
@@ -1292,7 +1294,10 @@ frappe.ui.form.on("Payment Entry", {
if (!row) {
const company_defaults = frappe.get_doc(":Company", frm.doc.company);
const is_single_currency =
frm.doc.paid_from_account_currency === frm.doc.paid_to_account_currency;
const account =
(is_single_currency && company_defaults?.bank_charges_account) ||
company_defaults?.[account_fieldname] ||
(await prompt_for_missing_account(frm, account_fieldname));
@@ -1847,7 +1852,7 @@ frappe.ui.form.on("Payment Entry Deduction", {
before_deductions_remove: function (doc, cdt, cdn) {
const row = frappe.get_doc(cdt, cdn);
if (row.is_exchange_gain_loss && row.amount) {
frappe.throw(__("Cannot delete Exchange Gain/Loss row"));
frappe.throw(__("Cannot delete a system-generated deduction row"));
}
},

View File

@@ -40,6 +40,7 @@ from erpnext.accounts.party import (
complete_contact_details,
get_default_contact,
get_party_account,
validate_party_company,
)
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
@@ -277,7 +278,8 @@ class PaymentEntry(AccountsController):
if not liability_account:
throw(
_("Please set default {0} in Company {1}").format(
frappe.bold(frappe.get_meta("Company").get_label(fieldname)), frappe.bold(self.company)
frappe.bold(frappe.get_meta("Company").get_translated_label(fieldname)),
frappe.bold(self.company),
)
)
@@ -660,7 +662,7 @@ class PaymentEntry(AccountsController):
def validate_mandatory(self):
for field in ("paid_amount", "received_amount", "source_exchange_rate", "target_exchange_rate"):
if not self.get(field):
frappe.throw(_("{0} is mandatory").format(_(self.meta.get_label(field))))
frappe.throw(_("{0} is mandatory").format(self.meta.get_translated_label(field)))
def validate_reference_documents(self):
valid_reference_doctypes = self.get_valid_reference_doctypes()
@@ -1135,14 +1137,22 @@ class PaymentEntry(AccountsController):
if not exchange_gain_loss_row:
values = frappe.get_cached_value(
"Company", self.company, ("exchange_gain_loss_account", "cost_center"), as_dict=True
"Company",
self.company,
("bank_charges_account", "exchange_gain_loss_account", "cost_center"),
as_dict=True,
)
is_single_currency = self.paid_from_account_currency == self.paid_to_account_currency
account = (
is_single_currency and values.bank_charges_account
) or values.exchange_gain_loss_account
for fieldname, value in values.items():
missing_fields = {"exchange_gain_loss_account": account, "cost_center": values.cost_center}
for fieldname, value in missing_fields.items():
if value:
continue
label = _(frappe.get_meta("Company").get_label(fieldname))
label = frappe.get_meta("Company").get_translated_label(fieldname)
return frappe.msgprint(
_("Please set {0} in Company {1} to account for Exchange Gain / Loss").format(
label, get_link_to_form("Company", self.company)
@@ -1155,7 +1165,7 @@ class PaymentEntry(AccountsController):
exchange_gain_loss_row = self.append(
"deductions",
{
"account": values.exchange_gain_loss_account,
"account": account,
"cost_center": values.cost_center,
"is_exchange_gain_loss": 1,
},
@@ -2426,6 +2436,7 @@ def get_party_details(company: str, party_type: str, party: str, date: str, cost
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
frappe.has_permission(party_type, ptype, party, throw=True)
validate_party_company(party_type, party, company)
party_account = get_party_account(party_type, party, company)
account_currency = get_account_currency(party_account)

View File

@@ -782,6 +782,94 @@ class TestPaymentEntry(ERPNextTestSuite):
self.validate_gl_entries(pe.name, expected_gle)
def test_bank_charges_deduction(self):
bank_charges_account = create_account(
parent_account="Indirect Expenses - _TC",
account_name="_Test Bank Charges",
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "bank_charges_account", bank_charges_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "bank_charges_account", "")
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
pe.company = "_Test Company"
pe.paid_from = "_Test Bank - _TC"
pe.paid_to = "_Test Cash - _TC"
pe.paid_amount = 1000
pe.received_amount = 990
pe.reference_no = "4"
pe.reference_date = nowdate()
pe.setup_party_account_field()
pe.set_missing_values()
pe.set_exchange_rate()
pe.set_amounts()
self.assertEqual(pe.deductions[0].account, bank_charges_account)
self.assertEqual(pe.deductions[0].amount, 10)
pe.deductions[0].cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
expected_gle = dict(
(d[0], d)
for d in [
["_Test Bank - _TC", 0, 1000, None],
["_Test Cash - _TC", 990, 0, None],
[bank_charges_account, 10, 0, None],
]
)
self.validate_gl_entries(pe.name, expected_gle)
def test_cross_currency_transfer_ignores_bank_charges_account(self):
exchange_gain_loss_account = frappe.db.get_value(
"Company", "_Test Company", "exchange_gain_loss_account"
)
bank_charges_account = create_account(
parent_account="Indirect Expenses - _TC",
account_name="_Test Bank Charges",
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "bank_charges_account", bank_charges_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "bank_charges_account", "")
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
pe.company = "_Test Company"
pe.paid_from = "_Test Bank USD - _TC"
pe.paid_to = "_Test Bank - _TC"
pe.paid_amount = 100
pe.source_exchange_rate = 50
pe.received_amount = 4500
pe.reference_no = "5"
pe.reference_date = nowdate()
pe.setup_party_account_field()
pe.set_missing_values()
pe.set_exchange_rate()
pe.set_amounts()
self.assertEqual(pe.deductions[0].account, exchange_gain_loss_account)
self.assertEqual(pe.deductions[0].amount, 500)
pe.deductions[0].cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
expected_gle = dict(
(d[0], d)
for d in [
["_Test Bank USD - _TC", 0, 5000, None],
["_Test Bank - _TC", 4500, 0, None],
[exchange_gain_loss_account, 500.0, 0, None],
]
)
self.validate_gl_entries(pe.name, expected_gle)
def test_payment_against_negative_sales_invoice(self):
si1 = create_sales_invoice()

View File

@@ -53,7 +53,7 @@
"depends_on": "eval:doc.is_exchange_gain_loss",
"fieldname": "is_exchange_gain_loss",
"fieldtype": "Check",
"label": "Is Exchange Gain / Loss?",
"label": "System Generated",
"read_only": 1
}
],

View File

@@ -647,7 +647,7 @@ class PaymentReconciliation(Document):
def check_mandatory_to_fetch(self):
for fieldname in ["company", "party_type", "party", "receivable_payable_account"]:
if not self.get(fieldname):
frappe.throw(_("Please select {0} first").format(_(self.meta.get_label(fieldname))))
frappe.throw(_("Please select {0} first").format(self.meta.get_translated_label(fieldname)))
def validate_entries(self):
if not self.get("invoices"):

View File

@@ -497,6 +497,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"depends_on": "customer",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
@@ -1642,7 +1643,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-06-21 12:46:13.250145",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -187,7 +187,10 @@ class PricingRule(Document):
tocheck = frappe.scrub(self.get("applicable_for", ""))
if tocheck and not self.get(tocheck):
throw(_("{0} is required").format(_(self.meta.get_label(tocheck))), frappe.MandatoryError)
throw(
_("{0} is required").format(self.meta.get_translated_label(tocheck)),
frappe.MandatoryError,
)
if self.apply_rule_on_other:
o_field = "other_" + frappe.scrub(self.apply_rule_on_other)

View File

@@ -512,6 +512,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List",
@@ -1695,7 +1696,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-08-05 15:40:16.519774",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -279,9 +279,7 @@ class PurchaseInvoice(BuyingController):
self.check_conversion_rate()
self.validate_credit_to_acc()
self.clear_unallocated_advances("Purchase Invoice Advance", "advances")
self.check_for_on_hold_or_closed_status(
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
)
self.check_purchase_order_on_hold_or_close("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")
@@ -297,6 +295,7 @@ class PurchaseInvoice(BuyingController):
BillingValidationService(self).validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
self.set_status()
self.validate_purchase_receipt_if_update_stock()
self.validate_exchange_rate_with_purchase_receipt()
validate_inter_company_party(
self.doctype, self.supplier, self.company, self.inter_company_invoice_reference
)
@@ -320,6 +319,47 @@ class PurchaseInvoice(BuyingController):
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_exchange_rate_with_purchase_receipt(self):
if self.is_internal_transfer() or not erpnext.is_perpetual_inventory_enabled(self.company):
return
stock_items = self.get_stock_items()
receipts = {
item.purchase_receipt
for item in self.items
if item.purchase_receipt and item.item_code in stock_items
}
if not receipts:
return
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"):
return
mismatched = [
f"{frappe.bold(row.name)} ({row.conversion_rate})"
for row in frappe.get_all(
"Purchase Receipt",
filters={"name": ("in", list(receipts))},
fields=["name", "currency", "conversion_rate"],
)
if row.currency == self.currency
and flt(row.conversion_rate)
and flt(row.conversion_rate) != flt(self.conversion_rate)
]
if not mismatched:
return
frappe.throw(
_(
"Exchange rate {0} does not match the exchange rate of Purchase Receipt {1}. Use the same exchange rate as the Purchase Receipt or enable {2} in {3} to adjust the landed cost based on this invoice."
).format(
frappe.bold(self.conversion_rate),
", ".join(mismatched),
frappe.bold(_("Set Landed Cost Based on Purchase Invoice Rate")),
get_link_to_form("Buying Settings", "Buying Settings", _("Buying Settings")),
)
)
def validate_invoice_hold(self):
if self.is_return:
frappe.throw(_("Return Purchase Invoice cannot be held."))
@@ -732,9 +772,7 @@ class PurchaseInvoice(BuyingController):
super().on_cancel()
PurchaseTaxWithholding(self).on_cancel()
self.check_for_on_hold_or_closed_status(
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
)
self.check_purchase_order_on_hold_or_close("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

@@ -583,6 +583,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
@@ -594,25 +600,15 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 80
self.assertRaises(frappe.ValidationError, pi.insert)
pi.conversion_rate = 70
pi.insert()
pi.submit()
# Get exchnage gain and loss account
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
# fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[0].base_net_amount - pr.items[0].base_net_amount
)
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
frappe.db.set_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
self.assertFalse(
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
@@ -620,7 +616,17 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
# Creating Purchase Invoice with USD currency
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = frappe.new_doc("Purchase Receipt")
pr.currency = "USD"
pr.company = "_Test Company with perpetual inventory"
@@ -634,34 +640,20 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"rate": 100,
},
)
pr.append(
"items",
{"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"},
)
pr.insert()
pr.submit()
# Createing purchase invoice against Purchase Receipt
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 70
pi.credit_to = "_Test Payable USD - TCP1"
pi.insert()
pi.submit()
# Get exchnage gain and loss account
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
# fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "credit"
self.assertFalse(
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[1].base_net_amount - pr.items[1].base_net_amount
)
self.assertEqual(flt(discrepancy_caused_by_exchange_rate_diff, 2), amount)
def test_purchase_invoice_change_naming_series(self):
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][1])
pi.insert()

View File

@@ -170,6 +170,10 @@
"shipping_address_section",
"shipping_address_name",
"shipping_address",
"shipping_contact_person",
"shipping_contact_display",
"shipping_contact_mobile",
"shipping_contact_email",
"shipping_addr_col_break",
"dispatch_address_name",
"dispatch_address",
@@ -297,7 +301,6 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Tax Id",
"print_hide": 1,
"read_only": 1
},
{
@@ -590,6 +593,46 @@
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "shipping_contact_person",
"fieldtype": "Link",
"hide_days": 1,
"hide_seconds": 1,
"label": "Shipping Contact Person",
"options": "Contact",
"print_hide": 1
},
{
"fetch_from": "shipping_contact_person.full_name",
"fieldname": "shipping_contact_display",
"fieldtype": "Small Text",
"hide_days": 1,
"hide_seconds": 1,
"label": "Shipping Contact",
"read_only": 1
},
{
"fetch_from": "shipping_contact_person.mobile_no",
"fieldname": "shipping_contact_mobile",
"fieldtype": "Small Text",
"hide_days": 1,
"hide_seconds": 1,
"label": "Shipping Contact Mobile No",
"options": "Phone",
"read_only": 1
},
{
"fetch_from": "shipping_contact_person.email_id",
"fieldname": "shipping_contact_email",
"fieldtype": "Data",
"hidden": 1,
"hide_days": 1,
"hide_seconds": 1,
"label": "Shipping Contact Email",
"options": "Email",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "company_address",
"fieldtype": "Link",
@@ -610,6 +653,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"depends_on": "customer",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
@@ -1940,6 +1984,7 @@
"allow_on_submit": 1,
"fieldname": "additional_discount_account",
"fieldtype": "Link",
"hidden": 1,
"label": "Discount Account",
"options": "Account"
},
@@ -2360,7 +2405,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-06-21 12:46:13.250145",
"modified": "2026-08-14 12:43:19.480555",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -29,6 +29,7 @@ from erpnext.setup.doctype.company.company import update_company_current_month_s
from erpnext.stock.doctype.delivery_note.services.billing_status import (
update_billed_amount_based_on_so,
)
from erpnext.stock.utils import get_bin_qty_map
from .services.fixed_assets import FixedAssetService
from .services.inter_company import (
@@ -201,6 +202,10 @@ class SalesInvoice(SellingController):
set_warehouse: DF.Link | None
shipping_address: DF.TextEditor | None
shipping_address_name: DF.Link | None
shipping_contact_display: DF.SmallText | None
shipping_contact_email: DF.Data | None
shipping_contact_mobile: DF.SmallText | None
shipping_contact_person: DF.Link | None
shipping_rule: DF.Link | None
status: DF.Literal[
"",
@@ -991,11 +996,17 @@ class SalesInvoice(SellingController):
)
def update_current_stock(self):
bin_qty_map = get_bin_qty_map(self.items + self.packed_items)
for item in self.items:
item.set_actual_qty()
if item.item_code and item.warehouse:
bin_data = bin_qty_map.get((item.item_code, item.warehouse))
item.actual_qty = bin_data.actual_qty if bin_data else 0
for packed_item in self.packed_items:
packed_item.set_actual_and_projected_qty()
bin_data = bin_qty_map.get((packed_item.item_code, packed_item.warehouse))
packed_item.actual_qty = bin_data.actual_qty if bin_data else 0
packed_item.projected_qty = bin_data.projected_qty if bin_data else 0
def update_packing_list(self):
if cint(self.update_stock) == 1:

View File

@@ -887,6 +887,7 @@
"allow_on_submit": 1,
"fieldname": "discount_account",
"fieldtype": "Link",
"hidden": 1,
"label": "Discount Account",
"options": "Account"
},
@@ -1067,7 +1068,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-08-11 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -114,15 +114,6 @@ class SalesInvoiceItem(Document):
)
)
def set_actual_qty(self):
if self.item_code and self.warehouse:
self.actual_qty = (
frappe.db.get_value(
"Bin", {"item_code": self.item_code, "warehouse": self.warehouse}, "actual_qty"
)
or 0
)
def set_income_account_for_fixed_asset(self, company: str):
"""Set income account for fixed asset item based on company's disposal account and cost center."""
if not self.is_fixed_asset:

View File

@@ -7,10 +7,9 @@ def get_data():
"non_standard_fieldnames": {
"Tax Rule": "sales_tax_template",
"Subscription": "sales_tax_template",
"Restaurant": "default_tax_template",
},
"transactions": [
{"label": _("Transactions"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
{"label": _("References"), "items": ["POS Profile", "Subscription", "Restaurant", "Tax Rule"]},
{"label": _("References"), "items": ["POS Profile", "Subscription", "Tax Rule"]},
],
}

View File

@@ -140,6 +140,7 @@ def _get_party_details(
if not ignore_permissions:
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
frappe.has_permission(party_type, ptype, party, throw=True)
validate_party_company(party_type, party.name, company)
currency = party.get("default_currency") or currency or get_company_currency(company)
@@ -155,7 +156,7 @@ def _get_party_details(
dispatch_address,
ignore_permissions=ignore_permissions,
)
set_contact_details(party_details, party, party_type)
set_contact_details(party_details, party, party_type, doctype)
set_other_values(party_details, party, party_type)
set_price_list(party_details, party, party_type, price_list, pos_profile)
@@ -197,6 +198,17 @@ def _get_party_details(
return party_details
def validate_party_company(party_type, party, company):
if not company or party_type not in ("Customer", "Supplier"):
return
from erpnext.stock.doctype.company_restriction.company_restriction import (
validate_masters_for_company,
)
validate_masters_for_company(party_type, [party], company)
def set_address_details(
party_details,
party,
@@ -346,10 +358,22 @@ def complete_contact_details(party_details):
party_details.update(contact_details)
def set_contact_details(party_details, party, party_type):
def set_contact_details(party_details, party, party_type, doctype=None):
party_details.contact_person = get_default_contact(party_type, party.name)
complete_contact_details(party_details)
# the shipping contact is picked by the user, so it has no default to fall back on;
# blank it instead of carrying the previous party's contact over
if doctype and frappe.get_meta(doctype).has_field("shipping_contact_person"):
party_details.update(
{
"shipping_contact_person": None,
"shipping_contact_display": None,
"shipping_contact_mobile": None,
"shipping_contact_email": None,
}
)
def set_other_values(party_details, party, party_type):
# copy

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -94,10 +94,15 @@ frappe.query_reports["Accounts Payable"] = {
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
let is_supplier = frappe.query_report.get_filter_value("party_type") === "Supplier";
let supplier_group_filter = frappe.query_report.get_filter("supplier_group");
if (supplier_group_filter) {
supplier_group_filter.df.hidden = !is_supplier;
}
frappe.query_report.toggle_filter_display("supplier_group", !is_supplier);
if (!is_supplier) {
frappe.query_report.set_filter_value("supplier_group", []);
}
},
},
{

View File

@@ -6,6 +6,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, query_builder, scrub
from frappe.permissions import get_allowed_docs_for_doctype
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
@@ -51,6 +52,7 @@ class ReceivablePayableReport:
self.filters = frappe._dict(filters or {})
self.qb_selection_filter = []
self.ple = qb.DocType("Payment Ledger Entry")
self.sales_person_records = None
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
@@ -91,6 +93,7 @@ class ReceivablePayableReport:
self.party_type = get_party_types_from_account_type(self.account_type)
self.party_details = {}
self.invoices = set()
self.sales_person_records = None
self.skip_total_row = 0
self.advance_payment_doctypes = get_advance_payment_doctypes()
@@ -205,7 +208,7 @@ class ReceivablePayableReport:
def get_invoices(self, ple):
if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"):
if self.filters.get("sales_person"):
if self.sales_person_records is not None:
if ple.voucher_no in self.sales_person_records.get(
"Sales Invoice", []
) or ple.party in self.sales_person_records.get("Customer", []):
@@ -236,7 +239,7 @@ class ReceivablePayableReport:
]
def get_voucher_balance(self, ple):
if self.filters.get("sales_person"):
if self.sales_person_records is not None:
if not (
ple.party in self.sales_person_records.get("Customer", [])
or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", [])
@@ -894,28 +897,54 @@ class ReceivablePayableReport:
self.ple_query = query
def get_permitted_sales_persons(self, parenttype):
if self.account_type != "Receivable":
return None
permissions = frappe.permissions.get_user_permissions(frappe.session.user).get("Sales Person", [])
if not permissions:
return None
return get_allowed_docs_for_doctype(permissions, parenttype)
def get_sales_invoices_or_customers_based_on_sales_person(self):
parenttypes = ["Customer", "Sales Invoice"]
permitted = {p: self.get_permitted_sales_persons(p) for p in parenttypes}
if not (self.filters.get("sales_person") or any(p is not None for p in permitted.values())):
return
steam = frappe.qb.DocType("Sales Team")
scope = []
for parenttype in parenttypes:
criterion = steam.parenttype == parenttype
if (allowed := permitted[parenttype]) is not None:
criterion &= steam.sales_person.isin(allowed or [""])
scope.append(criterion)
conditions = [Criterion.any(scope)]
if self.filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
steam = frappe.qb.DocType("Sales Team")
sp = frappe.qb.DocType("Sales Person")
records = (
frappe.qb.from_(steam)
.select(steam.parent, steam.parenttype)
.distinct()
.where(
steam.parenttype.isin(["Customer", "Sales Invoice"])
& steam.sales_person.isin(
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
)
conditions.append(
steam.sales_person.isin(
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
)
.run(as_dict=1)
)
self.sales_person_records = frappe._dict()
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
records = (
frappe.qb.from_(steam)
.select(steam.parent, steam.parenttype)
.distinct()
.where(Criterion.all(conditions))
.run(as_dict=1)
)
self.sales_person_records = frappe._dict()
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
def get_invoices_based_on_sales_partner(self):
if not self.filters.get("sales_partner"):

View File

@@ -323,9 +323,13 @@ def add_total_row_account(
consolidated=False,
add_blank_row=True,
):
name_key = "account" if consolidated else "section"
parent_key = "parent_account" if consolidated else "parent_section"
label_str = "'" + str(label) + "'"
total_row = {
"section_name": "'" + _("{0}").format(label) + "'",
"section": "'" + _("{0}").format(label) + "'",
f"{name_key}_name": label_str,
name_key: label_str,
"currency": currency,
}
@@ -336,15 +340,15 @@ def add_total_row_account(
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
for row in data:
if row.get("parent_section"):
if row.get(parent_key):
for period in period_list:
key = period if consolidated else period["key"]
total_row.setdefault(key, 0.0)
total_row[key] += row.get(key, 0.0)
summary_data[label] += row.get(key)
summary_data[label] += row.get(key) or 0.0
total_row.setdefault("total", 0.0)
total_row["total"] += row["total"]
total_row["total"] += row.get("total", 0.0)
out.append(total_row)
@@ -484,7 +488,6 @@ def get_opening_range_using_fiscal_year(company, period_list):
def get_report_summary(summary_data, currency):
report_summary = []
for label, value in summary_data.items():
report_summary.append({"value": value, "label": label, "datatype": "Currency", "currency": currency})

View File

@@ -175,7 +175,7 @@ frappe.query_reports["General Ledger"] = {
fieldname: "include_dimensions",
label: __("Consider Accounting Dimensions"),
fieldtype: "Check",
default: 1,
default: frappe.boot.sysdefaults.disable_include_dimensions ? 0 : 1,
},
{
fieldname: "disable_opening_balance_calculation",

View File

@@ -510,6 +510,7 @@ class GrossProfitGenerator:
self.average_buying_rate = {}
self.filters = frappe._dict(filters)
self.load_invoice_items()
self.load_drop_ship_buying_rates()
self.get_delivery_notes()
self.load_product_bundle()
@@ -518,6 +519,7 @@ class GrossProfitGenerator:
self.load_non_stock_items()
self.get_returned_invoice_items()
self.allocate_legacy_return_items()
self.process()
def process(self):
@@ -534,6 +536,8 @@ class GrossProfitGenerator:
base_amount = 0
for row in reversed(self.si_list):
sales_invoice_item = row.item_row
if self.filters.get("group_by") == "Monthly":
row.monthly = formatdate(row.posting_date, "MMM YYYY")
@@ -596,7 +600,7 @@ class GrossProfitGenerator:
row.buying_rate, row.base_rate = 0.0, 0.0
if self.is_not_invoice_row(row):
self.update_return_invoices(row)
self.update_return_invoices(row, sales_invoice_item)
if grouped_by_invoice and row.indent == 1.0:
buying_amount += row.buying_amount
@@ -623,26 +627,32 @@ class GrossProfitGenerator:
if self.grouped:
self.get_average_rate_based_on_group_by()
def update_return_invoices(self, row):
if row.parent in self.returned_invoices and row.item_code in self.returned_invoices[row.parent]:
returned_item_rows = self.returned_invoices[row.parent][row.item_code]
for returned_item_row in returned_item_rows:
# returned_items 'qty' should be stateful
if returned_item_row.qty != 0:
if row.qty >= abs(returned_item_row.qty):
row.qty += returned_item_row.qty
row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
returned_item_row.qty = 0
returned_item_row.base_amount = 0
def update_return_invoices(self, row, sales_invoice_item):
returned_item_rows = self.returned_invoices.get(row.parent, {}).get(sales_invoice_item)
if not returned_item_rows:
return
else:
row.qty = 0
row.base_amount = 0
returned_item_row.qty += row.qty
returned_item_row.base_amount += row.base_amount
for returned_item_row in returned_item_rows:
# returned_items 'qty' should be stateful
if returned_item_row.qty != 0:
if row.qty >= abs(returned_item_row.qty):
row.qty += returned_item_row.qty
row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
returned_item_row.qty = 0
returned_item_row.base_amount = 0
if not row.delivered_by_supplier:
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
else:
returned_item_row.qty += row.qty
returned_item_row.base_amount += row.base_amount
row.qty = 0
row.base_amount = 0
if row.delivered_by_supplier:
buying_amount = self.get_drop_ship_buying_amount(row)
if buying_amount is not None:
row.buying_amount = flt(buying_amount, self.currency_precision)
else:
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
def get_average_rate_based_on_group_by(self):
for key in list(self.grouped):
@@ -729,6 +739,7 @@ class GrossProfitGenerator:
.select(
si.name,
si_item.item_code,
si_item.sales_invoice_item,
si_item.stock_qty.as_("qty"),
si_item.base_net_amount.as_("base_amount"),
si.return_against,
@@ -742,17 +753,73 @@ class GrossProfitGenerator:
)
self.returned_invoices = frappe._dict()
self.legacy_returned_invoices = frappe._dict()
for inv in returned_invoices:
self.returned_invoices.setdefault(inv.return_against, frappe._dict()).setdefault(
inv.item_code, []
invoice_returns = (
self.returned_invoices if inv.sales_invoice_item else self.legacy_returned_invoices
)
invoice_returns.setdefault(inv.return_against, frappe._dict()).setdefault(
inv.sales_invoice_item or inv.item_code, []
).append(inv)
def skip_row(self, row):
if self.filters.get("group_by") != "Invoice":
if not row.get(scrub(self.filters.get("group_by", ""))):
return True
def allocate_legacy_return_items(self):
source_invoice_items = {}
for row in reversed(self.si_list):
if row.is_return or not row.parent or self.skip_row(row):
continue
return False
source_invoice_items.setdefault((row.parent, row.item_code), {}).setdefault(row.item_row, row.qty)
for invoice, legacy_invoice_items in self.legacy_returned_invoices.items():
returned_invoice_items = self.returned_invoices.setdefault(invoice, frappe._dict())
for item_code, legacy_item_rows in legacy_invoice_items.items():
targets = self.get_legacy_return_targets(
source_invoice_items.get((invoice, item_code), {}), returned_invoice_items
)
for legacy_item_row in legacy_item_rows:
self.allocate_legacy_return_item(legacy_item_row, targets, returned_invoice_items)
def get_legacy_return_targets(self, source_invoice_items, returned_invoice_items):
targets = []
for item_row, qty in source_invoice_items.items():
linked_return_qty = sum(
flt(returned_item.qty) for returned_item in returned_invoice_items.get(item_row, [])
)
if available_qty := max(flt(qty) + linked_return_qty, 0):
targets.append(frappe._dict(item_row=item_row, available_qty=available_qty))
targets.sort(key=lambda target: bool(returned_invoice_items.get(target.item_row)))
return targets
def allocate_legacy_return_item(self, legacy_item_row, targets, returned_invoice_items):
remaining_qty = abs(flt(legacy_item_row.qty))
remaining_base_amount = flt(legacy_item_row.base_amount)
if not remaining_qty:
return
qty_sign = -1 if legacy_item_row.qty < 0 else 1
for target in targets:
if not target.available_qty:
continue
allocated_qty = min(target.available_qty, remaining_qty)
allocated_item_row = frappe._dict(legacy_item_row.copy())
allocated_item_row.qty = qty_sign * allocated_qty
allocated_item_row.base_amount = remaining_base_amount * allocated_qty / remaining_qty
returned_invoice_items.setdefault(target.item_row, []).append(allocated_item_row)
target.available_qty -= allocated_qty
remaining_qty -= allocated_qty
remaining_base_amount -= allocated_item_row.base_amount
if not remaining_qty:
break
def skip_row(self, row):
group_by = self.filters.get("group_by")
if group_by in {"Invoice", "Monthly"}:
return False
return not row.get(scrub(group_by))
def get_buying_amount_from_product_bundle(self, row, product_bundle):
buying_amount = 0.0
@@ -786,28 +853,12 @@ class GrossProfitGenerator:
# IMP NOTE
# stock_ledger_entries should already be filtered by item_code and warehouse and
# sorted by posting_date desc, posting_time desc
if (
row.delivered_by_supplier
and row.so_detail
and (
po_details := frappe.get_all(
"Purchase Order Item",
filters={"sales_order_item": row.so_detail, "docstatus": 1},
pluck="name",
)
)
):
from frappe.query_builder.functions import Sum
if row.delivered_by_supplier:
buying_amount = self.get_drop_ship_buying_amount(row)
if buying_amount is not None:
return buying_amount
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty * table.base_net_rate))
.where((table.po_detail.isin(po_details)) & (table.docstatus == 1))
)
return flt(query.run()[0][0])
elif item_code in self.non_stock_items and (row.project or row.cost_center):
if item_code in self.non_stock_items and (row.project or row.cost_center):
# Issue 6089-Get last purchasing rate for non-stock item
item_rate = self.get_last_purchase_rate(item_code, row)
return flt(row.qty) * item_rate
@@ -838,6 +889,49 @@ class GrossProfitGenerator:
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
def load_drop_ship_buying_rates(self):
self.drop_ship_buying_rates = {}
sales_order_items = {
row.so_detail for row in self.si_list if row.delivered_by_supplier and row.so_detail
}
if not sales_order_items:
return
from frappe.query_builder.functions import Sum
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
buying_amounts = (
frappe.qb.from_(purchase_order_item)
.left_join(purchase_invoice_item)
.on(
(purchase_invoice_item.po_detail == purchase_order_item.name)
& (purchase_invoice_item.docstatus == 1)
)
.select(
purchase_order_item.sales_order_item,
Sum(purchase_invoice_item.qty * purchase_invoice_item.base_net_rate).as_("buying_amount"),
Sum(purchase_invoice_item.stock_qty).as_("stock_qty"),
)
.where(
(purchase_order_item.sales_order_item.isin(sales_order_items))
& (purchase_order_item.docstatus == 1)
)
.groupby(purchase_order_item.sales_order_item)
.run(as_dict=True)
)
for row in buying_amounts:
self.drop_ship_buying_rates[row.sales_order_item] = (
flt(row.buying_amount) / flt(row.stock_qty) if flt(row.stock_qty) else 0
)
def get_drop_ship_buying_amount(self, row):
if row.so_detail not in self.drop_ship_buying_rates:
return
return flt(row.qty) * self.drop_ship_buying_rates[row.so_detail]
def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code):
from frappe.query_builder.functions import Avg

View File

@@ -4,7 +4,7 @@ from frappe.utils import add_days, flt, get_first_day, get_last_day, nowdate
from erpnext.accounts.doctype.sales_invoice.mapper import make_delivery_note, make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.gross_profit.gross_profit import execute
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator, execute
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import create_item
@@ -676,19 +676,9 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertEqual(total[8], 0.0) # gross profit %
def test_drop_ship(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
from erpnext.selling.doctype.sales_order.mapper import make_purchase_order, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
so = make_sales_order(item=item.name, qty=10, rate=100)
po = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
po.items[0].rate = 80
po.supplier = "_Test Supplier"
po.submit()
make_purchase_invoice(po.name).submit()
so = self.create_drop_ship_order()
si = make_sales_invoice(so.name).submit()
filters = frappe._dict(
@@ -700,6 +690,357 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertIsNone(data[1].buying_rate)
self.assertEqual(data[1]["gross_profit_%"], 20)
def test_drop_ship_partial_billing_and_return(self):
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
so = self.create_drop_ship_order()
first_invoice = make_sales_invoice(so.name)
first_invoice.items[0].qty = 4
first_invoice.submit()
second_invoice = make_sales_invoice(so.name).submit()
filters = frappe._dict(
company=first_invoice.company,
from_date=first_invoice.posting_date,
to_date=first_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_rows = {
row.parent_invoice: row
for row in data
if row.parent_invoice in {first_invoice.name, second_invoice.name} and row.indent == 1
}
self.assertEqual(invoice_rows[first_invoice.name].buying_amount, 320)
self.assertEqual(invoice_rows[second_invoice.name].buying_amount, 480)
sales_return = make_sales_return(first_invoice.name)
sales_return.items[0].qty = -2
sales_return.submit()
_, data = execute(filters=filters)
first_invoice_row = next(
row for row in data if row.parent_invoice == first_invoice.name and row.indent == 1
)
self.assertEqual(first_invoice_row.qty, 2)
self.assertEqual(first_invoice_row.buying_amount, 160)
self.assertEqual(first_invoice_row.gross_profit, 40)
def test_drop_ship_return_matches_sales_invoice_item(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
from erpnext.selling.doctype.sales_order.mapper import make_purchase_order, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(
"_Test Drop Ship Consolidated Return Item",
properties={"is_stock_item": 1, "delivered_by_supplier": 1},
)
sales_orders = []
for qty, selling_rate, buying_rate in [(4, 100, 50), (6, 200, 80)]:
sales_order = make_sales_order(item=item.name, qty=qty, rate=selling_rate, do_not_submit=True)
sales_order.items[0].delivered_by_supplier = 1
sales_order.items[0].supplier = "_Test Supplier"
sales_order.submit()
sales_orders.append(sales_order)
purchase_order = make_purchase_order(sales_order.name, selected_items=[sales_order.items[0]])[0]
purchase_order.items[0].rate = buying_rate
purchase_order.supplier = "_Test Supplier"
purchase_order.submit()
make_purchase_invoice(purchase_order.name).submit()
sales_invoice = make_sales_invoice(sales_orders[0].name)
sales_invoice = make_sales_invoice(sales_orders[1].name, target_doc=sales_invoice).submit()
sales_return = make_sales_return(sales_invoice.name)
sales_return.set("items", [sales_return.items[0]])
sales_return.items[0].qty = -1
sales_return.submit()
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
self.assertEqual([row.qty for row in invoice_rows], [3, 6])
self.assertEqual([row.buying_amount for row in invoice_rows], [150, 480])
def test_return_matches_sales_invoice_item_for_delivery_note(self):
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=4,
basic_rate=50,
)
delivery_note = self.create_delivery_note(qty=4, rate=100)
sales_invoice = make_sales_invoice(delivery_note.name).submit()
sales_return = make_sales_return(sales_invoice.name)
sales_return.items[0].qty = -1
sales_return.submit()
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_row = next(
row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
)
self.assertEqual(invoice_row.qty, 3)
self.assertEqual(invoice_row.selling_amount, 300)
def test_return_combines_linked_and_legacy_item_buckets(self):
sales_invoice = self.create_sales_invoice(qty=4, rate=100)
linked_return = make_sales_return(sales_invoice.name)
linked_return.items[0].qty = -1
linked_return.submit()
legacy_return = make_sales_return(sales_invoice.name)
legacy_return.items[0].qty = -1
legacy_return.submit()
frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_row = next(
row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
)
self.assertEqual(invoice_row.qty, 2)
self.assertEqual(invoice_row.selling_amount, 200)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
def test_legacy_return_prefers_item_without_linked_return(self):
sales_invoice = self.create_sales_invoice(qty=2, rate=100, do_not_submit=True)
second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
second_item.rate = 200
sales_invoice.append("items", second_item)
sales_invoice.submit()
linked_return = make_sales_return(sales_invoice.name)
linked_return.set("items", [linked_return.items[0]])
linked_return.items[0].qty = -1
linked_return.submit()
legacy_return = make_sales_return(sales_invoice.name)
legacy_return.set("items", [legacy_return.items[1]])
legacy_return.items[0].qty = -1
legacy_return.submit()
frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
self.assertEqual([row.qty for row in invoice_rows], [1, 1])
self.assertEqual([row.selling_amount for row in invoice_rows], [100, 200])
def test_legacy_return_remainder_spills_into_linked_item(self):
invoice = "SINV-TEST-RETURN-ALLOCATION"
linked_item = "SINV-ITEM-LINKED"
unlinked_item = "SINV-ITEM-LEGACY"
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
generator.currency_precision = 3
generator.filters = frappe._dict(group_by="Invoice")
generator.returned_invoices = frappe._dict(
{invoice: frappe._dict({linked_item: [frappe._dict(qty=-1, base_amount=-100)]})}
)
generator.legacy_returned_invoices = frappe._dict(
{invoice: frappe._dict({self.item: [frappe._dict(qty=-2, base_amount=-200)]})}
)
linked_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=linked_item,
is_return=False,
qty=3,
base_amount=300,
buying_rate=50,
delivered_by_supplier=False,
)
unlinked_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=unlinked_item,
is_return=False,
qty=1,
base_amount=100,
buying_rate=50,
delivered_by_supplier=False,
)
generator.si_list = [unlinked_row, linked_row]
generator.allocate_legacy_return_items()
generator.update_return_invoices(linked_row, linked_item)
generator.update_return_invoices(unlinked_row, unlinked_item)
self.assertEqual((linked_row.qty, linked_row.base_amount), (1, 100))
self.assertEqual((unlinked_row.qty, unlinked_row.base_amount), (0, 0))
def test_legacy_return_ignores_skipped_group_rows(self):
invoice = "SINV-TEST-SKIPPED-RETURN-ALLOCATION"
visible_item = "SINV-ITEM-WITH-PROJECT"
skipped_item = "SINV-ITEM-WITHOUT-PROJECT"
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
generator.currency_precision = 3
generator.filters = frappe._dict(group_by="Project")
generator.returned_invoices = frappe._dict(
{invoice: frappe._dict({visible_item: [frappe._dict(qty=-1, base_amount=-100)]})}
)
generator.legacy_returned_invoices = frappe._dict(
{invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
)
visible_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=visible_item,
is_return=False,
project="_Test Project",
qty=2,
base_amount=200,
buying_rate=50,
delivered_by_supplier=False,
)
skipped_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=skipped_item,
is_return=False,
project=None,
qty=1,
)
generator.si_list = [visible_row, skipped_row]
generator.allocate_legacy_return_items()
generator.update_return_invoices(visible_row, visible_item)
self.assertNotIn(skipped_item, generator.returned_invoices[invoice])
self.assertEqual((visible_row.qty, visible_row.base_amount), (0, 0))
def test_monthly_group_allocates_legacy_return(self):
invoice = "SINV-TEST-MONTHLY-RETURN-ALLOCATION"
item_row = "SINV-ITEM-MONTHLY-RETURN"
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
generator.currency_precision = 3
generator.filters = frappe._dict(group_by="Monthly")
generator.returned_invoices = frappe._dict()
generator.legacy_returned_invoices = frappe._dict(
{invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
)
invoice_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=item_row,
is_return=False,
posting_date=nowdate(),
qty=1,
base_amount=100,
buying_rate=50,
delivered_by_supplier=False,
)
generator.si_list = [invoice_row]
generator.allocate_legacy_return_items()
generator.update_return_invoices(invoice_row, item_row)
self.assertEqual((invoice_row.qty, invoice_row.base_amount), (0, 0))
def test_return_remainder_stays_available_for_next_row(self):
invoice = "SINV-TEST-RETURN-REMAINDER"
item_row = "SINV-ITEM-RETURN-REMAINDER"
returned_item = frappe._dict(qty=-2, base_amount=-200)
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
generator.currency_precision = 3
generator.returned_invoices = frappe._dict({invoice: frappe._dict({item_row: [returned_item]})})
first_row = frappe._dict(
parent=invoice,
item_code=self.item,
qty=1,
base_amount=100,
buying_rate=50,
delivered_by_supplier=False,
)
second_row = first_row.copy()
generator.update_return_invoices(first_row, item_row)
self.assertEqual((returned_item.qty, returned_item.base_amount), (-1, -100))
generator.update_return_invoices(second_row, item_row)
self.assertEqual((returned_item.qty, returned_item.base_amount), (0, 0))
self.assertEqual((first_row.qty, second_row.qty), (0, 0))
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
def test_return_keeps_buying_amount_of_unreturned_row(self):
unreturned_item = create_item(
"_Test Gross Profit Unreturned Item", warehouse=self.warehouse, company=self.company
)
make_stock_entry(
company=self.company,
item_code=unreturned_item.name,
target=self.warehouse,
qty=40000,
basic_rate=33.33333,
)
sales_invoice = self.create_sales_invoice(qty=1, rate=100, do_not_submit=True)
second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
second_item.item_code = unreturned_item.name
second_item.item_name = unreturned_item.name
second_item.qty = 30000
sales_invoice.append("items", second_item)
sales_invoice.submit()
sales_return = make_sales_return(sales_invoice.name)
sales_return.set("items", [sales_return.items[0]])
sales_return.items[0].qty = -1
sales_return.submit()
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_row = next(
row
for row in data
if row.parent_invoice == sales_invoice.name and row.item_code == unreturned_item.name
)
self.assertEqual(invoice_row.qty, 30000)
self.assertEqual(invoice_row.buying_amount, 999999.9)
def create_drop_ship_order(self, qty=10, selling_rate=100, buying_rate=80):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
from erpnext.selling.doctype.sales_order.mapper import make_purchase_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
so = make_sales_order(item=item.name, qty=qty, rate=selling_rate)
purchase_order = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
purchase_order.items[0].rate = buying_rate
purchase_order.supplier = "_Test Supplier"
purchase_order.submit()
make_purchase_invoice(purchase_order.name).submit()
return so
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
"""Create a rate adjustment debit note with no stock movement."""
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)

View File

@@ -160,7 +160,8 @@ def _execute(filters, additional_table_columns=None):
row.update(
{
"debit": inv.base_grand_total,
"credit": 0.0,
# credits the invoice itself posts to the receivable (mirrors its GL)
"credit": get_in_invoice_receivable_credit(inv),
"outstanding_amount": flt(
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
),
@@ -181,6 +182,14 @@ def _execute(filters, additional_table_columns=None):
return columns, res, None, None, None, include_payments
def get_in_invoice_receivable_credit(inv):
# amount the invoice settles against its own receivable, matching the invoice's GL entries
credit = flt(inv.loyalty_amount) # loyalty redemption, POS or not
if inv.is_pos: # POS payments and write-off credit the receivable only on POS invoices
credit += flt(inv.base_paid_amount) - flt(inv.base_change_amount) + flt(inv.base_write_off_amount)
return credit
def get_columns(invoice_list, additional_table_columns, include_payments=False):
"""return columns based on filters"""
columns = [
@@ -458,6 +467,11 @@ def get_invoices(filters, additional_query_columns):
si.base_net_total,
si.base_grand_total,
si.base_rounded_total,
si.is_pos,
si.base_paid_amount,
si.base_change_amount,
si.base_write_off_amount,
si.loyalty_amount,
si.outstanding_amount,
si.is_internal_customer,
si.represents_company,

View File

@@ -1,6 +1,7 @@
import frappe
from frappe.utils import add_days, flt, getdate, today
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_register.sales_register import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -251,6 +252,46 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
self.assertDictEqual(result_output, expected_result)
def test_ledger_view_nets_pos_paid_invoice(self):
# A POS payment settles the receivable inside the invoice, so the ledger view must credit it
# and net to zero instead of showing a phantom outstanding.
make_pos_profile()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_save=1,
)
si.is_pos = 1
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
si = si.save().submit()
self.assertEqual(flt(si.outstanding_amount), 0.0)
filters = frappe._dict(
{
"from_date": today(),
"to_date": today(),
"company": self.company,
"include_payments": True,
"customer": self.customer,
}
)
rows = execute(filters)[1]
inv_row = next(x for x in rows if x.get("voucher_no") == si.name)
self.assertEqual(flt(inv_row.get("debit")), 100.0)
self.assertEqual(flt(inv_row.get("credit")), 100.0)
# running balance is unchanged by a fully-paid POS invoice
idx = rows.index(inv_row)
self.assertEqual(flt(inv_row.get("balance")), flt(rows[idx - 1].get("balance")))
def test_outstanding_currency_conversion(self):
foreign_invoice = create_sales_invoice(
customer="_Test Customer",

View File

@@ -183,7 +183,7 @@ class PartyValidator:
):
frappe.throw(
_("{0} does not belong to the Company {1}.").format(
_(doc.meta.get_label(field)), frappe.bold(doc.company)
doc.meta.get_translated_label(field), frappe.bold(doc.company)
)
)

View File

@@ -1168,7 +1168,7 @@ def get_company_default(company: str, fieldname: str, ignore_validation: bool =
if not ignore_validation and not value:
throw(
_("Please set default {0} in Company {1}").format(
_(frappe.get_meta("Company").get_label(fieldname)), company
frappe.get_meta("Company").get_translated_label(fieldname), company
)
)

View File

@@ -54,7 +54,7 @@ def book_depreciation_entries(date):
(depr_schedule_name, asset_name, sch_start_idx, sch_end_idx) = data
try:
make_depreciation_entry(
_make_depreciation_entry(
depr_schedule_name,
date,
sch_start_idx,
@@ -129,7 +129,7 @@ def get_companies_with_frozen_limits():
def make_depreciation_entry_on_disposal(asset_doc, disposal_date=None):
for row in asset_doc.get("finance_books"):
depr_schedule_name = get_asset_depr_schedule_name(asset_doc.name, "Active", row.finance_book)
make_depreciation_entry(depr_schedule_name, disposal_date)
_make_depreciation_entry(depr_schedule_name, disposal_date)
def get_credit_debit_accounts_for_asset(asset_category, company):
@@ -172,6 +172,22 @@ def make_depreciation_entry(
sch_start_idx: int | None = None,
sch_end_idx: int | None = None,
accounting_dimensions: list[dict] | None = None,
):
depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", depr_schedule_name)
frappe.has_permission("Asset Depreciation Schedule", "write", depr_schedule_doc, throw=True)
frappe.has_permission("Asset", "write", depr_schedule_doc.asset, throw=True)
return _make_depreciation_entry(
depr_schedule_name, date, sch_start_idx, sch_end_idx, accounting_dimensions
)
def _make_depreciation_entry(
depr_schedule_name: str,
date: DateTimeLikeObject | None = None,
sch_start_idx: int | None = None,
sch_end_idx: int | None = None,
accounting_dimensions: list[dict] | None = None,
):
frappe.has_permission("Journal Entry", throw=True)
date = date or today()

View File

@@ -152,6 +152,8 @@ class AssetCapitalization(StockController):
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
d.set(k, v)
self.split_valuation_rate_for_grouped_stock_items()
for d in self.asset_items:
args = self.as_dict()
args.update(d.as_dict())
@@ -173,6 +175,30 @@ class AssetCapitalization(StockController):
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
d.set(k, v)
def split_valuation_rate_for_grouped_stock_items(self):
groups = {}
for d in self.stock_items:
if d.item_code and d.warehouse and not (d.serial_no or d.batch_no or d.serial_and_batch_bundle):
groups.setdefault((d.item_code, d.warehouse), []).append(d)
for rows in groups.values():
if len(rows) < 2:
continue
cumulative_qty = 0.0
prev_cumulative_value = 0.0
for d in rows:
cumulative_qty += flt(d.stock_qty)
args = self.get_args_for_incoming_rate(d)
args["qty"] = -1 * cumulative_qty
cumulative_rate = flt(get_incoming_rate(args, raise_error_if_no_rate=False))
cumulative_value = cumulative_rate * cumulative_qty
row_value = cumulative_value - prev_cumulative_value
d.valuation_rate = flt(row_value / d.stock_qty) if flt(d.stock_qty) else 0.0
d.amount = flt(flt(d.stock_qty) * d.valuation_rate, d.precision("amount"))
prev_cumulative_value = cumulative_value
def validate_target_item(self):
target_item = frappe.get_cached_doc("Item", self.target_item_code)
@@ -306,6 +332,8 @@ class AssetCapitalization(StockController):
warehouse_details = get_warehouse_details(args)
d.update(warehouse_details)
self.split_valuation_rate_for_grouped_stock_items()
@frappe.whitelist()
def set_asset_values(self):
for d in self.get("asset_items"):

View File

@@ -397,6 +397,33 @@ class TestAssetCapitalization(ERPNextTestSuite):
actual_gle = get_actual_gle_dict(asset_capitalization.name)
self.assertEqual(actual_gle, {})
def test_grouped_stock_item_rows_split_fifo_rate(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company"
warehouse = create_warehouse("_Test Warehouse for Grouped FIFO Rows", company=company)
item = create_item(
"_Test Grouped FIFO Rows Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=1
)
target_item = create_fixed_asset_item("_Test Grouped FIFO Rows Target Item")
make_purchase_receipt(item_code=item.item_code, qty=1, rate=100, company=company, warehouse=warehouse)
make_purchase_receipt(item_code=item.item_code, qty=1, rate=200, company=company, warehouse=warehouse)
asset_capitalization = frappe.new_doc("Asset Capitalization")
asset_capitalization.company = company
asset_capitalization.target_item_code = target_item.name
asset_capitalization.append(
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
)
asset_capitalization.append(
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
)
asset_capitalization.insert()
rates = [d.valuation_rate for d in asset_capitalization.stock_items]
self.assertEqual(rates, [100, 200])
def create_asset_capitalization_data():
create_item("Capitalization Target Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0)

View File

@@ -116,24 +116,39 @@ frappe.ui.form.on("Asset Repair", {
},
repair_status: (frm) => {
if (frm.doc.completion_date && frm.doc.repair_status == "Completed") {
frappe.call({
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
args: {
failure_date: frm.doc.failure_date,
completion_date: frm.doc.completion_date,
},
callback: function (r) {
if (r.message) {
frm.set_value("downtime", r.message + " Hrs");
}
},
});
}
if (frm.doc.repair_status == "Completed" && !frm.doc.completion_date) {
frm.set_value("completion_date", frappe.datetime.now_datetime());
}
frm.events.set_downtime(frm);
},
failure_date: (frm) => {
frm.events.set_downtime(frm);
},
completion_date: (frm) => {
frm.events.set_downtime(frm);
},
set_downtime: (frm) => {
if (frm.doc.repair_status != "Completed" || !frm.doc.failure_date || !frm.doc.completion_date) {
frm.set_value("downtime", null);
return;
}
frappe.call({
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
args: {
failure_date: frm.doc.failure_date,
completion_date: frm.doc.completion_date,
},
callback: function (r) {
if (r.message) {
frm.set_value("downtime", r.message + " Hrs");
}
},
});
},
stock_items_on_form_rendered() {

View File

@@ -67,6 +67,7 @@ class AssetRepair(AccountsController):
self.calculate_repair_cost()
self.calculate_total_repair_cost()
self.check_repair_status()
self.set_downtime()
def validate_asset(self):
if self.asset_doc.status in ("Sold", "Scrapped"):
@@ -239,6 +240,13 @@ class AssetRepair(AccountsController):
if self.repair_status == "Pending" and self.docstatus == 1:
frappe.throw(_("Please update Repair Status."))
def set_downtime(self):
# keep downtime in sync with the entered dates, regardless of edit order
if self.repair_status == "Completed" and self.failure_date and self.completion_date:
self.downtime = f"{get_downtime(self.failure_date, self.completion_date)} Hrs"
else:
self.downtime = None
def update_asset_value(self):
total_repair_cost = self.total_repair_cost if self.docstatus == 1 else -1 * self.total_repair_cost

View File

@@ -98,6 +98,21 @@ class TestAssetRepair(ERPNextTestSuite):
asset_repair = create_asset_repair(submit=1)
self.assertNotEqual(asset_repair.repair_status, "Pending")
def test_downtime_stays_in_sync_with_dates(self):
asset = create_asset(submit=1)
asset_repair = create_asset_repair(asset=asset)
asset_repair.failure_date = "2026-07-31 09:00:00"
asset_repair.completion_date = "2026-07-31 11:00:00"
asset_repair.repair_status = "Completed"
asset_repair.save()
self.assertEqual(asset_repair.downtime, "2.0 Hrs")
# editing a date must refresh downtime, not leave a stale value
asset_repair.completion_date = "2026-07-31 14:30:00"
asset_repair.save()
self.assertEqual(asset_repair.downtime, "5.5 Hrs")
def test_stock_items(self):
asset_repair = create_asset_repair(stock_consumption=1)
self.assertTrue(asset_repair.stock_consumption)

View File

@@ -53,6 +53,15 @@ class BuyingSettings(Document):
for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]:
frappe.db.set_default(key, self.get(key, ""))
self.update_supplier_naming_settings()
if not self.bill_for_rejected_quantity_in_purchase_invoice:
self.set_valuation_rate_for_rejected_materials = 0
def update_supplier_naming_settings(self):
if not self.has_value_changed("supp_master_name"):
return
from erpnext.utilities.naming import set_by_naming_series
set_by_naming_series(
@@ -62,9 +71,6 @@ class BuyingSettings(Document):
hide_name_field=False,
)
if not self.bill_for_rejected_quantity_in_purchase_invoice:
self.set_valuation_rate_for_rejected_materials = 0
def before_save(self):
self.check_maintain_same_rate()

View File

@@ -1,9 +1,30 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from unittest.mock import patch
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestBuyingSettings(ERPNextTestSuite):
pass
def test_unrelated_change_does_not_update_supplier_metadata(self):
settings = frappe.get_single("Buying Settings")
settings.allow_multiple_items = not settings.allow_multiple_items
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
settings.save()
set_by_naming_series.assert_not_called()
def test_supplier_metadata_updates_when_related_settings_change(self):
settings = frappe.get_single("Buying Settings")
settings.supp_master_name = (
"Supplier Name" if settings.supp_master_name == "Naming Series" else "Naming Series"
)
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
settings.save()
set_by_naming_series.assert_called_once()

View File

@@ -396,6 +396,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List",
@@ -1298,7 +1299,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:34:19.659621",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -51,9 +51,9 @@ class SubcontractingService:
if not doc.is_subcontracted:
return
finished_goods_without_service_item = {
d.fg_item for d in doc.items if (not d.item_code and d.fg_item)
}
finished_goods_without_service_item = list(
{d.fg_item for d in doc.items if (not d.item_code and d.fg_item)}
)
if subcontracting_boms := get_subcontracting_boms_for_finished_goods(
finished_goods_without_service_item

View File

@@ -58,25 +58,26 @@ def make_supplier_quotation_from_rfq(
@frappe.whitelist(methods=["POST"])
def create_supplier_quotation(doc: str | Document | dict):
doc = frappe.parse_json(doc)
supplier = doc.get("supplier")
if frappe.session.user not in frappe.get_all(
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
):
if frappe.session.user not in frappe.get_all("Portal User", {"parent": supplier}, pluck="user"):
frappe.throw(_("Not Permitted"), frappe.PermissionError)
validate_existing_supplier_quotation(supplier, doc.get("items"))
sq_doc = frappe.get_doc(
{
"doctype": "Supplier Quotation",
"supplier": doc.get("supplier"),
"supplier": supplier,
"terms": doc.get("terms"),
"company": doc.get("company"),
"currency": doc.get("currency")
or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
or get_party_account_currency("Supplier", supplier, doc.get("company")),
"buying_price_list": doc.get("buying_price_list")
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
}
)
add_items(sq_doc, doc.get("supplier"), doc.get("items"))
add_items(sq_doc, supplier, doc.get("items"))
sq_doc.flags.ignore_permissions = True
sq_doc.run_method("set_missing_values")
sq_doc.save()
@@ -84,6 +85,45 @@ def create_supplier_quotation(doc: str | Document | dict):
return sq_doc.name
def validate_existing_supplier_quotation(supplier, items):
request_for_quotations = {item.get("parent") for item in items if item.get("parent")}
if not request_for_quotations:
return
rfq = frappe.qb.DocType("Request for Quotation")
(
frappe.qb.from_(rfq)
.select(rfq.name)
.where(rfq.name.isin(request_for_quotations))
.orderby(rfq.name)
.for_update()
).run()
sq = frappe.qb.DocType("Supplier Quotation")
sqi = frappe.qb.DocType("Supplier Quotation Item")
existing_quotation = (
frappe.qb.from_(sq)
.inner_join(sqi)
.on(sq.name == sqi.parent)
.select(sq.name, sqi.request_for_quotation)
.where(
(sq.docstatus < 2)
& (sq.supplier == supplier)
& (sqi.request_for_quotation.isin(request_for_quotations))
)
.limit(1)
).run(as_dict=True)
if existing_quotation:
existing_quotation = existing_quotation[0]
frappe.throw(
_("Supplier Quotation {0} already exists against Request for Quotation {1}").format(
frappe.bold(existing_quotation.name),
frappe.bold(existing_quotation.request_for_quotation),
)
)
def add_items(sq_doc, supplier, items):
for data in items:
if isinstance(data, dict):

View File

@@ -28,6 +28,7 @@ frappe.ui.form.on("Request for Quotation", {
is_group: 0,
},
}));
frm.set_query("supplier", "suppliers", () => erpnext.queries.supplier(frm.doc));
frm.set_indicator_formatter("item_code", function (doc) {
return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : "";
@@ -339,6 +340,7 @@ frappe.ui.form.on("Request for Quotation Supplier", {
args: {
party: d.supplier,
party_type: "Supplier",
company: frm.doc.company,
},
callback: function (r) {
if (r.message) {

View File

@@ -13,6 +13,7 @@ from frappe.utils import get_url
from frappe.utils.print_format import download_pdf
from frappe.utils.user import get_user_fullname
from erpnext.accounts.party import validate_party_frozen_disabled
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
@@ -122,6 +123,8 @@ class RequestforQuotation(BuyingController):
def validate_supplier_list(self):
for d in self.suppliers:
validate_party_frozen_disabled(self.company, "Supplier", d.supplier)
prevent_rfqs = frappe.db.get_value("Supplier", d.supplier, "prevent_rfqs")
if prevent_rfqs:
standing = frappe.db.get_value("Supplier Scorecard", d.supplier, "status")

View File

@@ -18,6 +18,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.exceptions import PartyDisabled
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
@@ -89,6 +90,17 @@ class TestRequestforQuotation(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, rfq.save)
def test_rfq_blocked_for_disabled_supplier(self):
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
rfq = make_request_for_quotation(
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
do_not_save=True,
)
self.assertRaises(PartyDisabled, rfq.save)
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
rfq.save()
def test_rfq_status_lifecycle(self):
rfq = make_request_for_quotation()
self.assertEqual(rfq.status, "Submitted")
@@ -188,6 +200,18 @@ class TestRequestforQuotation(ERPNextTestSuite):
self.assertEqual(supplier_quotation_doc.get("items")[0].qty, 5)
self.assertEqual(supplier_quotation_doc.get("items")[0].amount, 500)
def test_make_duplicate_supplier_quotation_from_portal(self):
rfq = make_request_for_quotation()
rfq.supplier = rfq.suppliers[0].supplier
supplier_quotation = frappe.get_doc("Supplier Quotation", create_supplier_quotation(rfq))
supplier_quotation.submit()
with self.assertRaisesRegex(frappe.ValidationError, "already exists"):
create_supplier_quotation(rfq)
supplier_quotation.cancel()
self.assertTrue(create_supplier_quotation(rfq))
def test_make_multi_uom_supplier_quotation(self):
item_code = "_Test Multi UOM RFQ Item"
if not frappe.db.exists("Item", item_code):

View File

@@ -40,6 +40,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Supplier",
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
"options": "Supplier",
"reqd": 1
},

View File

@@ -358,7 +358,7 @@
},
{
"default": "0",
"description": "Frozen suppliers block ledger entries until unfrozen. Use this to temporarily lock accounting activity without disabling the supplier.",
"description": "Frozen suppliers block new transactions and ledger entries until unfrozen. Only users with the role set in Company's \"Roles Allowed to Set and Edit Frozen Account Entries\" can transact.",
"fieldname": "is_frozen",
"fieldtype": "Check",
"label": "Is Frozen"
@@ -378,6 +378,7 @@
{
"fieldname": "primary_address_and_contact_detail_section",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Primary Address and Contact"
},
{
@@ -392,6 +393,7 @@
"fetch_from": "supplier_primary_contact.mobile_no",
"fieldname": "mobile_no",
"fieldtype": "Read Only",
"hidden": 1,
"label": "Mobile No",
"no_copy": 1
},
@@ -399,12 +401,14 @@
"fetch_from": "supplier_primary_contact.email_id",
"fieldname": "email_id",
"fieldtype": "Read Only",
"hidden": 1,
"label": "Email ID",
"no_copy": 1
},
{
"fieldname": "primary_address",
"fieldtype": "Text Editor",
"hidden": 1,
"label": "Primary Address Preview",
"no_copy": 1,
"read_only": 1
@@ -588,7 +592,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-07-23 10:00:00.000000",
"modified": "2026-08-14 16:10:58.600553",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",

View File

@@ -257,6 +257,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List",
@@ -947,7 +948,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:29:37.509487",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",

View File

@@ -85,6 +85,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
],
default: __("Categorize by Supplier"),
},
{
fieldname: "status",
label: __("Status"),
fieldtype: "Select",
options: [
{ label: "", value: "" },
{ label: __("Draft"), value: "Draft" },
{ label: __("Submitted"), value: "Submitted" },
],
default: "Submitted",
},
{
fieldtype: "Check",
label: __("Include Expired"),

View File

@@ -56,13 +56,20 @@ def get_data(filters):
)
.where(
(sq_item.parent == sq.name)
& (sq_item.docstatus < 2)
& (sq.company == filters.get("company"))
& (sq.transaction_date.between(filters.get("from_date"), filters.get("to_date")))
)
.orderby(sq.transaction_date, sq_item.item_code)
)
# blank -> Draft + Submitted, else filter to the chosen docstatus
if filters.get("status") == "Draft":
query = query.where(sq_item.docstatus == 0)
elif filters.get("status") == "Submitted":
query = query.where(sq_item.docstatus == 1)
else:
query = query.where(sq_item.docstatus < 2)
if filters.get("item_code"):
query = query.where(sq_item.item_code == filters.get("item_code"))

View File

@@ -14,7 +14,7 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
"""The report lists Supplier Quotation item lines so quotes for the same item can
be compared across suppliers."""
def make_quotation(self, supplier, qty, rate, uom=None):
def make_quotation(self, supplier, qty, rate, uom=None, submit=True):
item = {"item_code": ITEM, "qty": qty, "rate": rate, "warehouse": "_Test Warehouse - _TC"}
if uom:
item["uom"] = uom
@@ -29,7 +29,8 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
}
)
sq.insert()
sq.submit()
if submit:
sq.submit()
return sq
def run_report(self, **extra):
@@ -64,3 +65,21 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
self.assertIn(sq2.name, quotes)
self.assertEqual(quotes[sq1.name]["base_rate"], 100)
self.assertEqual(quotes[sq2.name]["base_rate"], 120)
def test_status_filter(self):
draft = self.make_quotation("_Test Supplier", qty=10, rate=100, submit=False)
submitted = self.make_quotation("_Test Supplier 1", qty=10, rate=120)
def names(**extra):
return {r["quotation"] for r in self.run_report(item_code=ITEM, **extra)}
# default (Submitted) hides drafts
self.assertNotIn(draft.name, names(status="Submitted"))
self.assertIn(submitted.name, names(status="Submitted"))
# Draft shows only drafts
self.assertIn(draft.name, names(status="Draft"))
self.assertNotIn(submitted.name, names(status="Draft"))
# blank shows both
both = names(status="")
self.assertIn(draft.name, both)
self.assertIn(submitted.name, both)

View File

@@ -524,7 +524,7 @@ class AccountsController(TransactionBase):
_(
"Please set {0} to {1}, the same account that was used in the original invoice {2}."
).format(
frappe.bold(_(self.meta.get_label(cr_dr_account_field), context=self.doctype)),
frappe.bold(self.meta.get_translated_label(cr_dr_account_field)),
frappe.bold(original_account),
frappe.bold(self.return_against),
)
@@ -535,6 +535,8 @@ class AccountsController(TransactionBase):
frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date"))
def before_print(self, settings=None):
self.set_missing_terms()
if self.doctype in [
"Purchase Order",
"Sales Order",
@@ -558,6 +560,16 @@ class AccountsController(TransactionBase):
set_print_templates_for_item_table(self, settings)
set_print_templates_for_taxes(self, settings)
def set_missing_terms(self):
if not self.get("tc_name") or self.get("terms"):
return
from erpnext.setup.doctype.terms_and_conditions.terms_and_conditions import (
get_terms_and_conditions,
)
self.terms = get_terms_and_conditions(self.tc_name, self.as_dict())
def calculate_paid_amount(self):
if hasattr(self, "is_pos") or hasattr(self, "is_paid"):
is_paid = self.get("is_pos") or self.get("is_paid")

View File

@@ -84,18 +84,10 @@ class BuyingController(SubcontractingController):
),
)
if (
self.get("company")
and (
default_buying_terms := frappe.get_value(
"Company", self.get("company"), "default_buying_terms"
)
)
and not self.get("tc_name")
and not self.get("terms")
):
self.tc_name = default_buying_terms
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
if self.get("company") and not self.get("terms"):
if not self.get("tc_name"):
self.tc_name = frappe.get_value("Company", self.company, "default_buying_terms")
self.set_missing_terms()
def validate_posting_date_with_po(self):
po_list = {x.purchase_order for x in self.items if x.purchase_order}
@@ -304,8 +296,8 @@ class BuyingController(SubcontractingController):
frappe.throw(
_("Row #{idx}: {from_warehouse_field} and {to_warehouse_field} cannot be same.").format(
idx=item.idx,
from_warehouse_field=_(item.meta.get_label("from_warehouse")),
to_warehouse_field=_(item.meta.get_label("warehouse")),
from_warehouse_field=item.meta.get_translated_label("from_warehouse"),
to_warehouse_field=item.meta.get_translated_label("warehouse"),
)
)
@@ -710,7 +702,7 @@ class BuyingController(SubcontractingController):
frappe.throw(
_("Row #{idx}: {field_label} is mandatory.").format(
idx=d.idx,
field_label=_(d.meta.get_label("conversion_factor")),
field_label=d.meta.get_translated_label("conversion_factor"),
)
)
d.stock_qty = flt(d.qty) * flt(d.conversion_factor)
@@ -757,7 +749,7 @@ class BuyingController(SubcontractingController):
frappe.throw(
_("Row #{idx}: {field_label} can not be negative for item {item_code}.").format(
idx=item_row["idx"],
field_label=_(frappe.get_meta(item_row.doctype).get_label(fieldname)),
field_label=frappe.get_meta(item_row.doctype).get_translated_label(fieldname),
item_code=frappe.bold(item_row["item_code"]),
)
)
@@ -929,6 +921,13 @@ class BuyingController(SubcontractingController):
item.serial_and_batch_bundle, warehouse, type_of_transaction=type_of_transaction
)
def check_purchase_order_on_hold_or_close(self, ref_fieldname, exclude_if_field=None):
if self.get("is_return"):
return
self.check_for_on_hold_or_closed_status(
"Purchase Order", ref_fieldname, exclude_if_field=exclude_if_field
)
def update_ordered_and_reserved_qty(self):
po_map = {}
for d in self.get("items"):
@@ -942,7 +941,7 @@ class BuyingController(SubcontractingController):
if po and po_item_rows:
po_obj = frappe.get_lazy_doc("Purchase Order", po)
if po_obj.status in ["Closed", "Cancelled"]:
if po_obj.status == "Cancelled" or (po_obj.status == "Closed" and not self.get("is_return")):
frappe.throw(
_("{doctype} {name} is cancelled or closed.").format(
doctype=frappe.bold(_("Purchase Order")),
@@ -1203,14 +1202,14 @@ class BuyingController(SubcontractingController):
frappe.throw(
_("Row #{idx}: {schedule_date} cannot be before {transaction_date}.").format(
idx=d.idx,
schedule_date=_(self.meta.get_label("schedule_date")),
transaction_date=_(self.meta.get_label("transaction_date")),
schedule_date=self.meta.get_translated_label("schedule_date"),
transaction_date=self.meta.get_translated_label("transaction_date"),
)
)
else:
frappe.throw(
_("Please enter the {schedule_date}.").format(
schedule_date=_(self.meta.get_label("schedule_date"))
schedule_date=self.meta.get_translated_label("schedule_date")
)
)

View File

@@ -6,7 +6,7 @@ import json
from collections import OrderedDict, defaultdict
import frappe
from frappe import qb, scrub
from frappe import _, qb, scrub
from frappe.permissions import has_permission
from frappe.query_builder import Case, Criterion, DocType
from frappe.query_builder.functions import (
@@ -212,6 +212,65 @@ def tax_account_query(doctype: str, txt: str, searchfield: str, start: int, page
return tax_accounts
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def party_query(
doctype: str,
txt: str,
searchfield: str,
start: int,
page_len: int,
filters: dict | str | None = None,
):
party_name_field = {"Customer": "customer_name", "Supplier": "supplier_name"}.get(doctype)
if not party_name_field:
frappe.throw(_("Invalid party type: {0}").format(doctype))
filters = frappe.parse_json(filters) if filters else {}
if not isinstance(filters, dict):
frappe.throw(_("Party query filters must be a dictionary"))
company = filters.pop("company", None)
fields = get_fields(doctype, ["name", party_name_field])
party = DocType(doctype)
search_str = f"%{txt}%"
txt_no_percent = txt.replace("%", "")
search_fields = list(dict.fromkeys([searchfield, *fields]))
search_conditions = [party[field].like(search_str) for field in search_fields]
query = (
frappe.qb.get_query(doctype, fields=fields, filters=filters, ignore_permissions=False)
.where(party.docstatus < 2)
.where(Criterion.any(search_conditions))
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(party.name)) > 0,
Locate(Lower(txt_no_percent), Lower(party.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(party[party_name_field])) > 0,
Locate(Lower(txt_no_percent), Lower(party[party_name_field])),
)
.else_(99999)
)
.orderby(party.idx, order=Order.desc)
.orderby(party.name)
.orderby(party[party_name_field])
.limit(page_len)
.offset(start)
)
if company:
query = query.where(get_restriction_criterion(doctype, [company]))
return query.run()
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_query(
@@ -1133,9 +1192,8 @@ def get_filtered_child_rows(
):
table = frappe.qb.DocType(doctype)
query = (
frappe.qb.from_(table)
frappe.get_query(table, filters=filters)
.select(
table.name,
Concat("#", table.idx, ", ", table.item_code),
)
.orderby(table.idx)
@@ -1143,10 +1201,6 @@ def get_filtered_child_rows(
.limit(page_len)
)
if filters:
for field, value in filters.items():
query = query.where(table[field] == value)
if txt:
txt += "%"
query = query.where(

View File

@@ -31,7 +31,9 @@ def validate_return(doc):
def validate_return_against(doc):
if not frappe.db.exists(doc.doctype, doc.return_against):
frappe.throw(_("Invalid {0}: {1}").format(doc.meta.get_label("return_against"), doc.return_against))
frappe.throw(
_("Invalid {0}: {1}").format(doc.meta.get_translated_label("return_against"), doc.return_against)
)
else:
ref_doc = frappe.get_doc(doc.doctype, doc.return_against)
@@ -40,7 +42,7 @@ def validate_return_against(doc):
if ref_doc.get(party_type) != doc.get(party_type):
frappe.throw(
_("The {0} {1} does not match with the {0} {2} in the {3} {4}").format(
doc.meta.get_label(party_type),
doc.meta.get_translated_label(party_type),
bold(doc.get(party_type)),
bold(ref_doc.get(party_type)),
ref_doc.doctype,
@@ -215,7 +217,7 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
else 0
)
if column == "stock_qty" and not args.get("return_qty_from_rejected_warehouse"):
if column in ("stock_qty", "qty") and not args.get("return_qty_from_rejected_warehouse"):
reference_qty = ref.get(column)
current_stock_qty = args.get(column)
elif args.get("return_qty_from_rejected_warehouse"):
@@ -813,7 +815,7 @@ def get_rate_for_return(
if not (rate and return_against) and voucher_type in ["Sales Invoice", "Delivery Note"]:
rate = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "incoming_rate")
if not rate and sle:
if rate is None and sle:
rate = get_incoming_rate(
{
"item_code": sle.item_code,

View File

@@ -44,18 +44,10 @@ class SellingController(StockController):
),
)
if (
self.get("company")
and (
default_selling_terms := frappe.get_value(
"Company", self.get("company"), "default_selling_terms"
)
)
and not self.get("tc_name")
and not self.get("terms")
):
self.tc_name = default_selling_terms
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
if self.get("company") and not self.get("terms"):
if not self.get("tc_name"):
self.tc_name = frappe.get_value("Company", self.company, "default_selling_terms")
self.set_missing_terms()
def validate(self):
super().validate()
@@ -215,7 +207,7 @@ class SellingController(StockController):
if not (0 <= self.commission_rate <= 100.0):
throw(
"{} {}".format(
_(self.meta.get_label("commission_rate")),
self.meta.get_translated_label("commission_rate"),
_("must be between 0 and 100"),
)
)
@@ -306,7 +298,7 @@ class SellingController(StockController):
bold(ref_rate_field),
bold("net rate"),
bold(rate),
bold(frappe.get_meta("Selling Settings").get_label("validate_selling_price")),
bold(frappe.get_meta("Selling Settings").get_translated_label("validate_selling_price")),
get_link_to_form("Selling Settings"),
),
title=_("Invalid Selling Price"),
@@ -588,12 +580,12 @@ class SellingController(StockController):
reset_incoming_rate()
if (
not d.incoming_rate
(not d.incoming_rate or self.is_new())
and not is_standalone
or self.is_internal_transfer()
or (
get_valuation_method(d.item_code, self.company) == "Moving Average"
and self.get("is_return")
and not is_standalone
)
):
d.incoming_rate = get_incoming_rate(

View File

@@ -662,13 +662,16 @@ class StatusUpdater(Document):
update_data = {}
if args.get("target_parent_field"):
update_data[args.get("target_parent_field")] = self._calculate_target_parent_percentage(
args["name"],
args["target_parent_dt"],
args["target_dt"],
args["target_ref_field"],
args["target_field"],
)
if args.get("billing_percentage") is not None:
update_data[args.get("target_parent_field")] = args["billing_percentage"]
else:
update_data[args.get("target_parent_field")] = self._calculate_target_parent_percentage(
args["name"],
args["target_parent_dt"],
args["target_dt"],
args["target_ref_field"],
args["target_field"],
)
# update field
if args.get("status_field"):
update_data[args.get("status_field")] = self._determine_status(

View File

@@ -31,7 +31,7 @@ from erpnext.exceptions import (
)
from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock import get_warehouse_account_map
from erpnext.stock import get_warehouse_account, get_warehouse_account_map
from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.services.internal_transfer import StockInternalTransferService
from erpnext.stock.stock_ledger import get_items_to_be_repost
@@ -103,7 +103,7 @@ class StockController(AccountsController):
_("Row #{0}: Item {1} has zero rate but '{2}' is not enabled.").format(
item.idx,
frappe.bold(item.item_code),
item.meta.get_label("allow_zero_valuation_rate"),
item.meta.get_translated_label("allow_zero_valuation_rate"),
),
indicator="orange",
)
@@ -135,7 +135,9 @@ class StockController(AccountsController):
def use_item_inventory_account(self):
return frappe.get_cached_value("Company", self.company, "enable_item_wise_inventory_account")
def get_inventory_account_dict(self, row, inventory_account_map, warehouse_field=None):
def get_inventory_account_dict(
self, row, inventory_account_map, warehouse_field=None, *, raise_error=True
):
account_dict = frappe._dict()
if isinstance(row, dict):
@@ -164,8 +166,15 @@ class StockController(AccountsController):
if not warehouse:
warehouse = self.get(warehouse_field)
if warehouse and warehouse in inventory_account_map:
account_dict = inventory_account_map[warehouse]
if warehouse:
account_dict = inventory_account_map.get(warehouse)
if not account_dict and raise_error:
account = get_warehouse_account(frappe.get_cached_doc("Warehouse", warehouse))
account_dict = frappe._dict(
account=account,
account_currency=frappe.get_cached_value("Account", account, "account_currency"),
)
inventory_account_map[warehouse] = account_dict
return account_dict
@@ -306,27 +315,74 @@ class StockController(AccountsController):
validate_warehouse_company(w, self.company)
def update_billing_percentage(self, update_modified=True):
target_ref_field = "amount"
args = {
"target_dt": self.doctype + " Item",
"target_parent_dt": self.doctype,
"target_parent_field": "per_billed",
"target_ref_field": "amount",
"target_field": "billed_amt",
"name": self.name,
}
if self.doctype == "Delivery Note":
total_amount = total_returned = 0
for item in self.items:
total_amount += flt(item.amount)
total_returned += flt(item.returned_qty * item.rate)
# Bill by amount, falling back to qty when the invoiced amount is short (e.g. rate drop).
args["billing_percentage"] = self.get_delivery_note_billing_percentage()
if total_returned < total_amount:
target_ref_field = {"SUB": ["amount", {"MUL": ["returned_qty", "rate"]}], "as": "ref_amount"}
self._update_percent_field(args, update_modified)
self._update_percent_field(
{
"target_dt": self.doctype + " Item",
"target_parent_dt": self.doctype,
"target_parent_field": "per_billed",
"target_ref_field": target_ref_field,
"target_field": "billed_amt",
"name": self.name,
},
update_modified,
def get_delivery_note_billing_percentage(self):
invoiced_qty_map = self.get_invoiced_qty_map()
# Read fresh values; billed_amt is set on the rows just before this runs.
items = frappe.get_all(
"Delivery Note Item",
filters={"parent": self.name, "parenttype": "Delivery Note"},
fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt"],
)
total_amount = sum(flt(item.amount) for item in items)
total_returned = sum(flt(item.returned_qty) * flt(item.rate) for item in items)
# Preserve the original amount basis once the entire Delivery Note is returned.
use_original_amount = total_returned >= total_amount
total_ref = total_billed = 0.0
for item in items:
net_amount = abs(
flt(item.amount)
if use_original_amount
else flt(item.amount) - flt(item.returned_qty) * flt(item.rate)
)
if not net_amount:
continue
# Amount basis, capped at the delivery amount (mirrors _update_percent_field).
amount_billed = min(abs(flt(item.billed_amt)), net_amount)
# Qty basis: only raises billing when the amount is short; SO/SI-linked rows have
# no invoiced qty here, so the amount basis wins via max() below.
net_qty = flt(item.qty) - flt(item.returned_qty)
invoiced_qty = flt(invoiced_qty_map.get(item.name, 0))
qty_billed = net_amount * min(invoiced_qty / net_qty, 1) if net_qty else 0
total_ref += net_amount
total_billed += max(amount_billed, qty_billed)
return round(total_billed / total_ref * 100, 6) if total_ref else 0
def get_invoiced_qty_map(self):
from erpnext.stock.doctype.delivery_note.services.billing_status import (
get_invoiced_qty_against_dn,
get_invoiced_qty_based_on_so,
)
# Direct Delivery Note -> Sales Invoice billing
qty_map = get_invoiced_qty_against_dn(delivery_note=self.name)
# Sales Order -> Delivery Note -> Sales Invoice-from-SO billing: attribute qty via
# so_detail using the same FIFO distribution as update_billed_amount_based_on_so.
for so_detail in {item.so_detail for item in self.items if item.so_detail}:
qty_map.update(get_invoiced_qty_based_on_so(so_detail))
return qty_map
def validate_inspection(self):
from erpnext.stock.services.quality_inspection_service import QualityInspectionService
@@ -702,6 +758,11 @@ def is_reposting_pending():
)
def invalidate_future_sle_cache(voucher_type, voucher_no):
if hasattr(frappe.local, "future_sle"):
frappe.local.future_sle.pop((voucher_type, voucher_no), None)
def future_sle_exists(args, sl_entries=None):
from erpnext.stock.utils import get_combine_datetime

View File

@@ -157,7 +157,7 @@ class calculate_taxes_and_totals:
validate_conversion_rate(
self.doc.currency,
self.doc.conversion_rate,
self.doc.meta.get_label("conversion_rate"),
self.doc.meta.get_translated_label("conversion_rate"),
self.doc.company,
)
@@ -938,8 +938,9 @@ class calculate_taxes_and_totals:
item.net_amount = flt(
item.net_amount + rounding_difference, item.precision("net_amount")
)
# net_amount went up by rounding_difference, so its discount share goes down
item.distributed_discount_amount = flt(
distributed_amount + rounding_difference,
distributed_amount - rounding_difference,
item.precision("distributed_discount_amount"),
)
net_total += rounding_difference

View File

@@ -60,6 +60,30 @@ class TestTaxesAndTotals(ERPNextTestSuite):
self.assertAlmostEqual(so.net_total, 1272.73, places=2)
self.assertEqual(so.grand_total, 1400)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_distributed_discount_amount_with_rounding_adjustment(self):
so = make_sales_order(do_not_save=1)
so.apply_discount_on = "Net Total"
so.discount_amount = 10
so.items[0].qty = 1
so.items[0].rate = 100
so.append("items", so.items[0].as_dict())
so.append("items", so.items[0].as_dict())
so.save()
calculate_taxes_and_totals(so)
# the rounding adjustment lands on the second line
self.assertAlmostEqual(so.items[1].net_amount, 96.66, places=2)
self.assertAlmostEqual(so.items[1].distributed_discount_amount, 3.34, places=2)
for item in so.items:
self.assertAlmostEqual(item.amount - item.distributed_discount_amount, item.net_amount, places=2)
self.assertAlmostEqual(
sum(i.distributed_discount_amount for i in so.items), so.discount_amount, places=2
)
self.assertEqual(so.net_total, 290)
def test_100_percent_discount_with_inclusive_tax(self):
"""Test that 100% discount with inclusive taxes results in zero net_total"""
so = make_sales_order(do_not_save=1)

View File

@@ -46,3 +46,61 @@ class TestReactivity(ERPNextTestSuite):
with self.subTest(field=field):
self.assertIsNotNone(itm.get(field[0]))
si.save().submit()
def test_item_change_clears_stale_item_details(self):
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
old_item = make_item(properties={"is_stock_item": 0, "stock_uom": "Nos"})
new_item = make_item(
properties={
"is_stock_item": 0,
"stock_uom": "Kg",
"weight_per_unit": 2,
"weight_uom": "Kg",
}
)
sales_order = make_sales_order(item_code=old_item.name, do_not_submit=True)
item = sales_order.items[0]
self.assertEqual(item.uom, "Nos")
row_state = (item.qty, item.warehouse, item.delivery_date)
sales_order.ignore_pricing_rule = 1
item.weight_per_unit = 10
item.weight_uom = "Nos"
item.barcode = "OLD-BARCODE"
item.pricing_rules = "OLD-PRICING-RULE"
item.item_code = new_item.name
sales_order.process_item_selection(item.idx, reset_item_details=True)
self.assertEqual(item.uom, "Kg")
self.assertEqual(item.stock_uom, "Kg")
self.assertEqual(item.conversion_factor, 1)
self.assertEqual(item.weight_per_unit, 2)
self.assertEqual(item.weight_uom, "Kg")
self.assertIsNone(item.barcode)
self.assertFalse(item.pricing_rules)
self.assertEqual((item.qty, item.warehouse, item.delivery_date), row_state)
def test_programmatic_item_selection_preserves_explicit_uom(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(
properties={
"is_stock_item": 0,
"stock_uom": "Kg",
"sales_uom": "Nos",
"weight_per_unit": 2,
"weight_uom": "Kg",
},
uoms=[{"uom": "Nos", "conversion_factor": 10}],
)
sales_invoice = create_sales_invoice(item_code=item.name, uom="Kg", do_not_save=True)
sales_invoice.process_item_selection(sales_invoice.items[0].idx)
self.assertEqual(sales_invoice.items[0].uom, "Kg")
self.assertEqual(sales_invoice.items[0].conversion_factor, 1)
self.assertEqual(sales_invoice.items[0].stock_qty, sales_invoice.items[0].qty)

View File

@@ -110,3 +110,35 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite):
return_si.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_si.save)
def test_sales_invoice_partial_return_with_different_stock_uom(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.item.test_item import make_item
item_properties = {"is_stock_item": 1, "stock_uom": "Kg"}
if frappe.get_meta("Item").has_field("gst_hsn_code") and frappe.db.exists("GST HSN Code", "010121"):
item_properties["gst_hsn_code"] = "010121"
item = make_item(
"_Test SI Return Different Stock UOM",
item_properties,
uoms=[{"uom": "Nos", "conversion_factor": 0.013888889}],
)
si = create_sales_invoice(item_code=item.name, qty=48, do_not_save=True)
si.items[0].uom = "Nos"
si.items[0].stock_uom = "Kg"
si.items[0].conversion_factor = 0.013888889
si.save().submit()
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
first_return = make_return_doc(si.doctype, si.name)
first_return.items[0].qty = -24
first_return.save().submit()
self.addCleanup(self._cancel_and_delete, "Sales Invoice", first_return.name)
second_return = make_return_doc(si.doctype, si.name)
self.assertEqual(second_return.items[0].qty, -24)
second_return.save().submit()
self.addCleanup(self._cancel_and_delete, "Sales Invoice", second_return.name)

View File

@@ -40,3 +40,140 @@ class TestStockControllerConversions(ERPNextTestSuite):
sl_entries = [frappe._dict(item_code=item, warehouse="_Test Warehouse - _TC")]
self.assertTrue(future_sle_exists(args, sl_entries))
def _make_opening_entry(self, item, warehouse):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
opening = make_stock_entry(
item_code=item,
target=warehouse,
qty=100,
basic_rate=100,
posting_date=add_days(today(), -5),
posting_time="01:00:00",
)
self.addCleanup(self._cancel_and_delete, "Stock Entry", opening.name)
return opening
def _later_sle(self, item, warehouse, opening):
sle = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"item_code": item,
"warehouse": warehouse,
"posting_date": today(),
"posting_time": "12:00:00",
"voucher_type": "Stock Entry",
"voucher_no": opening.name,
"actual_qty": 7,
"incoming_rate": 100,
"qty_after_transaction": 107,
"valuation_rate": 100,
"stock_value": 10700,
"company": opening.company,
"stock_uom": "Nos",
}
)
sle.flags.ignore_permissions = True
sle.flags.ignore_links = True
return sle
def _submit_entry(self, item, warehouse, inject=None):
from erpnext.stock import stock_ledger
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
original_make_entry = stock_ledger.make_entry
injected = []
def make_entry_with_injection(*args, **kwargs):
if inject is not None and not injected:
injected.append(True)
inject.submit()
return original_make_entry(*args, **kwargs)
stock_ledger.make_entry = make_entry_with_injection
try:
entry = make_stock_entry(
item_code=item,
target=warehouse,
qty=5,
basic_rate=500,
posting_date=today(),
posting_time="06:00:00",
)
finally:
stock_ledger.make_entry = original_make_entry
self.addCleanup(self._cancel_and_delete, "Stock Entry", entry.name)
if inject is not None:
self.assertTrue(injected, "the later SL Entry was not written during the submit")
return entry
def _reposts_queued_for(self, item, warehouse, voucher_no):
names = set(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "item_code": item, "warehouse": warehouse},
pluck="name",
)
) | set(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "voucher_no": voucher_no},
pluck="name",
)
)
for name in names:
self.addCleanup(frappe.delete_doc, "Repost Item Valuation", name, force=1)
return names
def test_repost_queued_for_entry_backdated_while_its_sl_entries_were_written(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Concurrent Backdated Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
opening = self._make_opening_entry(item, warehouse)
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
self.assertTrue(
self._reposts_queued_for(item, warehouse, backdated.name),
"No Repost Item Valuation was queued for an entry that a later SL Entry made backdated",
)
def test_repost_queued_against_voucher_when_item_based_reposting_is_off(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Voucher Based Repost Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
with self.change_settings("Stock Reposting Settings", item_based_reposting=0):
opening = self._make_opening_entry(item, warehouse)
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
self.assertTrue(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "voucher_no": backdated.name},
pluck="name",
),
"No voucher based Repost Item Valuation was queued",
)
def test_no_repost_queued_when_nothing_was_written_after_the_entry(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Unconcurrent Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
self._make_opening_entry(item, warehouse)
entry = self._submit_entry(item, warehouse)
self.assertFalse(
self._reposts_queued_for(item, warehouse, entry.name),
"A Repost Item Valuation was queued for an entry with nothing posted after it",
)

View File

@@ -13,6 +13,7 @@ from frappe.model.document import Document
from frappe.share import add_docshare
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
from frappe.utils.data import sha256_hash
from frappe.utils.html_utils import escape_html
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
@@ -269,7 +270,11 @@ class Appointment(Document):
if self.customer_details:
lead.append(
"notes",
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
{
"note": escape_html(self.customer_details),
"added_by": frappe.session.user,
"added_on": now(),
},
)
self.party = lead.insert(ignore_permissions=True).name

View File

@@ -69,6 +69,13 @@ class CRMSettings(Document):
self.allowed_users = []
def custom_fields_for_frappe_crm_data_sync(self):
custom_fields = self.get_frappe_crm_custom_fields()
if self.enable_frappe_crm_data_synchronization:
create_custom_fields(custom_fields, ignore_validate=True)
@staticmethod
def get_frappe_crm_custom_fields():
custom_fields = {
"Quotation": [
{
@@ -88,4 +95,4 @@ class CRMSettings(Document):
],
}
create_custom_fields(custom_fields, ignore_validate=True)
return custom_fields

View File

@@ -12,6 +12,7 @@ from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from frappe.utils import flt, get_fullname
from erpnext.accounts.party import validate_party_frozen_disabled
from erpnext.crm.utils import (
CRMNote,
copy_comments,
@@ -132,6 +133,7 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_item_details()
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.validate_party()
self.map_fields()
self.validate_qty()
self.set_exchange_rate()
@@ -355,6 +357,10 @@ class Opportunity(TransactionBase, CRMNote):
return False
return True
def validate_party(self) -> None:
if self.opportunity_from == "Customer":
validate_party_frozen_disabled(self.company, "Customer", self.party_name)
def validate_cust_name(self):
if self.party_name:
if self.opportunity_from == "Customer":

View File

@@ -9,6 +9,7 @@ from erpnext.crm.doctype.lead.test_lead import make_lead
from erpnext.crm.doctype.opportunity.mapper import make_quotation
from erpnext.crm.doctype.opportunity.opportunity import auto_close_opportunity, get_item_details
from erpnext.crm.utils import get_linked_communication_list
from erpnext.exceptions import PartyDisabled
from erpnext.tests.utils import ERPNextTestSuite
@@ -71,6 +72,23 @@ class TestOpportunity(ERPNextTestSuite):
opportunity_doc = make_opportunity(with_items=1, rate=1100, qty=2)
self.assertEqual(opportunity_doc.total, 2200)
def test_disabled_customer_not_allowed(self):
frappe.db.set_value("Customer", "_Test Customer", "disabled", 1)
self.assertRaises(PartyDisabled, make_opportunity, with_items=0)
frappe.db.set_value("Customer", "_Test Customer", "disabled", 0)
make_opportunity(with_items=0)
def test_disabled_lead_not_blocked(self):
# Lead.disabled isn't enforced anywhere else (e.g. the Lead picker query only
# excludes Converted leads), so it shouldn't block Opportunity creation either.
lead_doc = make_lead()
frappe.db.set_value("Lead", lead_doc.name, "disabled", 1)
opp_doc = make_opportunity(opportunity_from="Lead", lead=lead_doc.name)
self.assertEqual(opp_doc.party_name, lead_doc.name)
def test_carry_forward_of_email_and_comments(self):
frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1)
lead_doc = make_lead()

View File

@@ -1,47 +0,0 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.crm.doctype.utils import get_last_interaction
from erpnext.tests.utils import ERPNextTestSuite
class TestCrmDoctypeUtils(ERPNextTestSuite):
def test_get_last_interaction_for_contact(self):
"""Covers the converted Communication query (contact path): returns the earliest Received
communication across the doctypes the contact is linked to. `creation` is unique, so the
LIMIT-1 pick is deterministic and identical on MariaDB and Postgres."""
customer = "_Test CRM Util Customer"
if not frappe.db.exists("Customer", customer):
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": customer,
"customer_group": "_Test Customer Group",
"territory": "_Test Territory",
}
).insert(ignore_permissions=True)
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "CRM Util Test",
"links": [{"link_doctype": "Customer", "link_name": customer}],
}
).insert(ignore_permissions=True)
comm = frappe.get_doc(
{
"doctype": "Communication",
"subject": "hi",
"content": "first interaction",
"sent_or_received": "Received",
"reference_doctype": "Customer",
"reference_name": customer,
}
).insert(ignore_permissions=True)
result = get_last_interaction(contact=contact.name)
self.assertIsNotNone(result["last_communication"])
self.assertEqual(result["last_communication"]["name"], comm.name)

View File

@@ -1,48 +1,4 @@
import frappe
from frappe.query_builder import Criterion
@frappe.whitelist()
def get_last_interaction(contact: str | None = None, lead: str | None = None):
if not contact and not lead:
return
last_communication = None
last_issue = None
if contact:
communication = frappe.qb.DocType("Communication")
link_conditions = []
contact = frappe.get_doc("Contact", contact)
for link in contact.links:
if link.link_doctype == "Customer":
last_issue = get_last_issue_from_customer(link.link_name)
link_conditions.append(
(communication.reference_doctype == link.link_doctype)
& (communication.reference_name == link.link_name)
)
if link_conditions:
last_communication = (
frappe.qb.from_(communication)
.select(communication.name, communication.content)
.where((communication.sent_or_received == "Received") & Criterion.any(link_conditions))
.orderby(communication.creation)
.limit(1)
.run(as_dict=1)
)
if lead:
last_communication = frappe.get_all(
"Communication",
filters={"reference_doctype": "Lead", "reference_name": lead, "sent_or_received": "Received"},
fields=["name", "content"],
order_by="creation desc",
limit=1,
)
last_communication = last_communication[0] if last_communication else None
return {"last_communication": last_communication, "last_issue": last_issue}
def get_last_issue_from_customer(customer_name):

View File

@@ -656,16 +656,16 @@ regional_overrides = {
"erpnext.controllers.accounts_controller.validate_regional": "erpnext.regional.italy.utils.sales_invoice_validate",
},
}
user_privacy_documents = [
user_data_fields = [
{
"doctype": "Lead",
"match_field": "email_id",
"personal_fields": ["phone", "mobile_no", "fax", "website", "lead_name"],
"filter_by": "email_id",
"redact_fields": ["phone", "mobile_no", "fax", "website", "lead_name"],
},
{
"doctype": "Opportunity",
"match_field": "contact_email",
"personal_fields": ["contact_mobile", "contact_display", "customer_name"],
"filter_by": "contact_email",
"redact_fields": ["contact_mobile", "contact_display", "customer_name"],
},
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More