mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-26 13:25:20 +00:00
Merge branch 'develop' into party-import-tool-integrated-with-data-import-tool
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -91,3 +91,5 @@ pull_request_rules:
|
||||
commit_message_format:
|
||||
title: pr-title
|
||||
body: pr-body
|
||||
merge_queue:
|
||||
queue_controls_comment: false
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
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
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
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
@@ -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", []);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Supplier",
|
||||
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
|
||||
"options": "Supplier",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
4003
erpnext/locale/ar.po
4003
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3997
erpnext/locale/bg.po
3997
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
4046
erpnext/locale/bs.po
4046
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3997
erpnext/locale/cs.po
3997
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
4007
erpnext/locale/da.po
4007
erpnext/locale/da.po
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
Reference in New Issue
Block a user