mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-21 20:37:15 +00:00
Compare commits
70 Commits
pot_develo
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c251254b8 | ||
|
|
04c949a662 | ||
|
|
5d00fec1c2 | ||
|
|
bb16dca4f7 | ||
|
|
79fdc8add3 | ||
|
|
6e87694bbe | ||
|
|
6eda8c8c62 | ||
|
|
fb69724cda | ||
|
|
dd72c2688b | ||
|
|
8fd0175b06 | ||
|
|
1e35af3abe | ||
|
|
db6e089109 | ||
|
|
a2481e9390 | ||
|
|
dc4b390a9b | ||
|
|
ff70aa6a33 | ||
|
|
4bbd07944d | ||
|
|
622b7e0caa | ||
|
|
3d1ec937cf | ||
|
|
6cc500e28d | ||
|
|
40ab41278e | ||
|
|
4197932f60 | ||
|
|
aebb160e76 | ||
|
|
5d5eeb5e02 | ||
|
|
022ee73d78 | ||
|
|
340feb94a1 | ||
|
|
4f312fcba4 | ||
|
|
f3c2a836eb | ||
|
|
f97660d10e | ||
|
|
00b7e6e9eb | ||
|
|
0f66c41819 | ||
|
|
2aab7f4f72 | ||
|
|
228bbe845f | ||
|
|
3384c1939b | ||
|
|
14275e4c01 | ||
|
|
60913b722a | ||
|
|
0e31182dca | ||
|
|
ded6df3614 | ||
|
|
cb409d710a | ||
|
|
4e3e301c90 | ||
|
|
863aa45e51 | ||
|
|
1d8ce1ee8c | ||
|
|
62d0e2c82e | ||
|
|
970f32a342 | ||
|
|
4b858ec0af | ||
|
|
f6b907ef9d | ||
|
|
2273448f1b | ||
|
|
b439bdda68 | ||
|
|
4974372073 | ||
|
|
2ad4a8c4a4 | ||
|
|
00ee694444 | ||
|
|
6cee9c330c | ||
|
|
f73d818ff4 | ||
|
|
29335f56b2 | ||
|
|
965124ba7c | ||
|
|
2fff457e34 | ||
|
|
8e14cd2394 | ||
|
|
514c3b45d0 | ||
|
|
da377683d6 | ||
|
|
9be19e69b5 | ||
|
|
41ad83dfb1 | ||
|
|
09dea8b1b5 | ||
|
|
f3dcc6180f | ||
|
|
198d40446f | ||
|
|
5a63b36c3b | ||
|
|
990a43ed16 | ||
|
|
9d3675405a | ||
|
|
b85da1628d | ||
|
|
5a8126412a | ||
|
|
5d1ffa7fca | ||
|
|
d39e677d80 |
7
.github/POSTGRES_COMPATIBILITY.md
vendored
7
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -180,6 +180,13 @@ audit of these fixes found four recurring mistakes:
|
||||
the arbitrary-pick preservation the wrap is usually justified as. Confirmed on CI; see #56241.
|
||||
Note a local macOS PostgreSQL gives a **false all-clear** — its collation happens to agree with
|
||||
MariaDB on case. Fix: take a representative row rather than sorting text.
|
||||
**Picking that row is the hard part.** `Min(name)` is still a text sort: `autoname="hash"` is
|
||||
not reliably lower case, because `_get_timestamp_prefix()` prepends `get_trace_id()[-1:]`
|
||||
un-lowered and a client-supplied `X-Frappe-Request-Id` can put an upper case `A-F` there. A
|
||||
non-text key (`Min(idx)`) works only where it is **unique within the group** and the join-back
|
||||
carries the **full group key** — a date is usually neither, and joining on a duplicated value
|
||||
turns one group into several rows (§3). Otherwise select the row in Python, sorting with
|
||||
`key=str.casefold` so the order matches MariaDB's collation without depending on the database's.
|
||||
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
|
||||
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
|
||||
average for a rate. A blind `Max` can understate urgency or overstate a figure.
|
||||
|
||||
@@ -22,6 +22,6 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
|
||||
- uses: alyf-de/po-review-action@57fff275f4a0518a2ca55869ec6776fa3813b3d5 # v1.2.0
|
||||
with:
|
||||
hidden-po-files: eo.po
|
||||
|
||||
@@ -55,6 +55,18 @@ class ERPNextAddress(Address):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_shipping_address(company: str, address: str | None = None):
|
||||
# `company` is caller supplied and this returns that company's own registered address with every
|
||||
# field. `select` rather than `read` on Company: Delivery, Maintenance, Purchase Manager and
|
||||
# Stock Manager all fill in transactions that ask for this while holding no Company `read` row.
|
||||
frappe.has_permission("Company", ptype="select", throw=True)
|
||||
|
||||
# and scope it to the caller's own Company restrictions, which costs nobody who has none
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Address")
|
||||
if allowed_companies and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
filters = [
|
||||
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||
["Dynamic Link", "link_name", "=", company],
|
||||
|
||||
@@ -24,7 +24,7 @@ def get(
|
||||
heatmap_year: str | None = None,
|
||||
):
|
||||
if chart_name:
|
||||
chart = frappe.get_doc("Dashboard Chart", chart_name)
|
||||
chart = frappe.get_doc("Dashboard Chart", chart_name, check_permission="read")
|
||||
else:
|
||||
chart = frappe._dict(frappe.parse_json(chart))
|
||||
timespan = chart.timespan
|
||||
@@ -46,6 +46,9 @@ def get(
|
||||
if not account:
|
||||
frappe.throw(_("Account filter not set!"))
|
||||
|
||||
# authorise the account itself, as get_balance_on() does; doc= brings User Permissions with it
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
if not to_date:
|
||||
to_date = nowdate()
|
||||
if not from_date:
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"description": "Heads (or groups) against which Accounting Entries are made and balances are maintained.",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/chart-of-accounts",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"properties",
|
||||
@@ -200,7 +201,7 @@
|
||||
"options": "Account Category"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-money",
|
||||
"icon": "vault",
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -503,24 +503,21 @@ class Account(NestedSet):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
Account = frappe.qb.DocType("Account")
|
||||
|
||||
search_field_obj = getattr(Account, searchfield)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Account)
|
||||
.select(Account.name)
|
||||
.where(Account.is_group == 1)
|
||||
.where(Account.docstatus != 2)
|
||||
.where(Account.company == filters["company"])
|
||||
.where(search_field_obj.like(f"%{txt}%"))
|
||||
.order_by(Account.name)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
return frappe.get_list(
|
||||
"Account",
|
||||
filters=[
|
||||
["is_group", "=", 1],
|
||||
["docstatus", "!=", 2],
|
||||
["company", "=", filters["company"]],
|
||||
[searchfield, "like", f"%{txt}%"],
|
||||
],
|
||||
fields=["name"],
|
||||
order_by="name",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
return query.run(as_list=1)
|
||||
|
||||
|
||||
def get_account_currency(account):
|
||||
"""Helper function to get account currency"""
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "folder-tree",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [
|
||||
{
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
"precision": "9"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-list",
|
||||
"icon": "scale",
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-22 19:13:50.400404",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "field:label",
|
||||
"creation": "2019-05-04 18:13:37.002352",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-dimensions",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"document_type",
|
||||
@@ -49,6 +50,7 @@
|
||||
"options": "Accounting Dimension Detail"
|
||||
}
|
||||
],
|
||||
"icon": "layers",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:05:56.890002",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -223,8 +223,11 @@ def delete_accounting_dimension(doc):
|
||||
frappe.clear_cache(doctype=doctype)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def disable_dimension(doc: str):
|
||||
# toggle_disabling rewrites a Custom Field site-wide, so demand the write that configures dimensions
|
||||
frappe.has_permission("Accounting Dimension", "write", throw=True)
|
||||
|
||||
if frappe.in_test:
|
||||
toggle_disabling(doc=doc)
|
||||
else:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "format:{accounting_dimension}-{#####}",
|
||||
"creation": "2020-11-08 18:28:11.906146",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-dimension-filter",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -99,6 +100,7 @@
|
||||
"label": "Fieldname"
|
||||
}
|
||||
],
|
||||
"icon": "funnel",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-08 14:13:22.203011",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "field:period_name",
|
||||
"creation": "2018-04-13 18:50:14.672323",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-period",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -77,6 +78,7 @@
|
||||
"options": "Role"
|
||||
}
|
||||
],
|
||||
"icon": "calendar-range",
|
||||
"links": [],
|
||||
"modified": "2026-03-09 17:15:33.577217",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"creation": "2013-06-24 15:49:57",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounts-settings",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -801,7 +802,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "icon-cog",
|
||||
"icon": "settings",
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"creation": "2024-10-16 16:57:12.085072",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"icon": "book-open",
|
||||
"is_submittable": 1,
|
||||
"field_order": [
|
||||
"company",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"creation": "2018-04-07 16:59:59.496668",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -100,6 +101,7 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "landmark",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:39.423431",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"creation": "2017-05-29 21:35:13.136357",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-account",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"account_name",
|
||||
@@ -227,6 +228,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "credit-card",
|
||||
"links": [
|
||||
{
|
||||
"group": "Transactions",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "wallet",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-16 22:17:48.007982",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"icon": "credit-card",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:37.221876",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"icon": "credit-card",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:37.347035",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "fa fa-check",
|
||||
"icon": "badge-check",
|
||||
"idx": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"creation": "2016-12-17 10:43:35.731631",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-guarantee",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -216,6 +217,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "shield-check",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-25 18:12:10.768835",
|
||||
|
||||
@@ -60,6 +60,9 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
|
||||
if not isinstance(reference_name, str):
|
||||
raise TypeError("reference_name must be a string")
|
||||
|
||||
# the form is the boundary, not the referenced order: an order guard would break one of the two roles
|
||||
frappe.has_permission("Bank Guarantee", throw=True)
|
||||
|
||||
fields_to_fetch = ["grand_total"]
|
||||
|
||||
if bank_guarantee_type == "Receiving":
|
||||
@@ -70,4 +73,14 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
|
||||
doctype = "Purchase Order"
|
||||
fields_to_fetch.append("supplier")
|
||||
|
||||
# and scope the referenced order to the caller's own Company restrictions, which costs nobody
|
||||
# who has none
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "Bank Guarantee")
|
||||
if allowed_companies:
|
||||
company = frappe.db.get_value(doctype, reference_name, "company")
|
||||
if company and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
return frappe.db.get_value(doctype, reference_name, fields_to_fetch, as_dict=True)
|
||||
|
||||
@@ -68,6 +68,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
frappe.msgprint(__("Please select Bank Account"));
|
||||
return;
|
||||
}
|
||||
frm.events.validate_dates(frm);
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.auto_reconcile_vouchers",
|
||||
args: {
|
||||
@@ -82,7 +83,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
});
|
||||
|
||||
frm.add_custom_button(__("Get Unreconciled Entries"), function () {
|
||||
frm.trigger("make_reconciliation_tool");
|
||||
return frm.trigger("make_reconciliation_tool");
|
||||
});
|
||||
frm.change_custom_button_type(__("Get Unreconciled Entries"), null, "primary");
|
||||
|
||||
@@ -106,7 +107,24 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
frm.trigger("get_account_opening_balance");
|
||||
},
|
||||
|
||||
validate_dates(frm) {
|
||||
const from_date = frm.doc.filter_by_reference_date
|
||||
? frm.doc.from_reference_date
|
||||
: frm.doc.bank_statement_from_date;
|
||||
const to_date = frm.doc.filter_by_reference_date
|
||||
? frm.doc.to_reference_date
|
||||
: frm.doc.bank_statement_to_date;
|
||||
if (from_date && to_date && from_date > to_date) {
|
||||
frappe.throw(
|
||||
frm.doc.filter_by_reference_date
|
||||
? __("From Reference Date cannot be greater than To Reference Date")
|
||||
: __("From Date cannot be greater than To Date")
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
make_reconciliation_tool(frm) {
|
||||
frm.events.validate_dates(frm);
|
||||
frm.get_field("reconciliation_tool_cards").$wrapper.empty();
|
||||
if (frm.doc.company && frm.doc.bank_account && frm.doc.bank_statement_to_date) {
|
||||
frm.trigger("get_cleared_balance").then(() => {
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "arrow-left-right",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -9,7 +9,7 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import cint, create_batch, flt
|
||||
from frappe.utils import cint, create_batch, flt, getdate
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
|
||||
@@ -54,6 +54,8 @@ def get_bank_transactions(
|
||||
all_transactions: bool = False,
|
||||
):
|
||||
# returns bank transactions for a bank account
|
||||
validate_date_range(from_date, to_date)
|
||||
|
||||
filters = []
|
||||
filters.append(["bank_account", "=", bank_account])
|
||||
filters.append(["docstatus", "=", 1])
|
||||
@@ -790,6 +792,7 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"deposit",
|
||||
"withdrawal",
|
||||
"bank_account",
|
||||
"company",
|
||||
"currency",
|
||||
"unallocated_amount",
|
||||
"date",
|
||||
@@ -824,11 +827,7 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"paid_from": paid_from,
|
||||
"paid_to": paid_to,
|
||||
"paid_amount": bank_transaction.unallocated_amount,
|
||||
"base_paid_amount": bank_transaction.unallocated_amount,
|
||||
"received_amount": bank_transaction.unallocated_amount,
|
||||
"base_received_amount": bank_transaction.unallocated_amount,
|
||||
"target_exchange_rate": 1,
|
||||
"source_exchange_rate": 1,
|
||||
"reference_date": bank_transaction.date,
|
||||
"posting_date": bank_transaction.date,
|
||||
"reference_no": (bank_transaction.reference_number or bank_transaction.description or "")[
|
||||
@@ -837,6 +836,8 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
}
|
||||
)
|
||||
|
||||
set_multi_currency_amounts(payment_entry_doc)
|
||||
|
||||
payment_entry_doc.insert()
|
||||
payment_entry_doc.submit()
|
||||
|
||||
@@ -875,6 +876,7 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
|
||||
"doctype": "Payment Entry",
|
||||
}
|
||||
)
|
||||
set_multi_currency_amounts(payment_entry)
|
||||
payment_entry.insert()
|
||||
payment_entry.submit()
|
||||
transaction = reconcile_vouchers(
|
||||
@@ -897,6 +899,33 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
|
||||
}
|
||||
|
||||
|
||||
def set_multi_currency_amounts(pe):
|
||||
"""Set real exchange rates when the bank and party accounts differ in currency."""
|
||||
company_currency = frappe.get_cached_value("Company", pe.company, "default_currency")
|
||||
pe.paid_from_account_currency = frappe.get_cached_value("Account", pe.paid_from, "account_currency")
|
||||
pe.paid_to_account_currency = frappe.get_cached_value("Account", pe.paid_to, "account_currency")
|
||||
|
||||
pe.source_exchange_rate = (
|
||||
1.0
|
||||
if pe.paid_from_account_currency == company_currency
|
||||
else get_exchange_rate(pe.paid_from_account_currency, company_currency, pe.posting_date)
|
||||
)
|
||||
pe.target_exchange_rate = (
|
||||
1.0
|
||||
if pe.paid_to_account_currency == company_currency
|
||||
else get_exchange_rate(pe.paid_to_account_currency, company_currency, pe.posting_date)
|
||||
)
|
||||
|
||||
# derive the party-side amount from the authoritative bank-side amount; Payment Entry books any
|
||||
# rounding residual to Exchange Gain/Loss during validation (set_exchange_gain_loss)
|
||||
if pe.payment_type == "Receive" and pe.source_exchange_rate:
|
||||
base_amount = flt(pe.received_amount) * pe.target_exchange_rate
|
||||
pe.paid_amount = flt(base_amount / pe.source_exchange_rate, pe.precision("paid_amount"))
|
||||
elif pe.payment_type == "Pay" and pe.target_exchange_rate:
|
||||
base_amount = flt(pe.paid_amount) * pe.source_exchange_rate
|
||||
pe.received_amount = flt(base_amount / pe.target_exchange_rate, pe.precision("received_amount"))
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET"])
|
||||
def search_for_transfer_transaction(transaction_id: str | int):
|
||||
"""
|
||||
@@ -962,9 +991,10 @@ def auto_reconcile_vouchers(
|
||||
from_date: str | date | None = None,
|
||||
to_date: str | date | None = None,
|
||||
filter_by_reference_date: bool | None = None,
|
||||
from_reference_date: bool | None = None,
|
||||
to_reference_date: str | None = None,
|
||||
from_reference_date: str | date | None = None,
|
||||
to_reference_date: str | date | None = None,
|
||||
):
|
||||
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
|
||||
bank_transactions = get_bank_transactions(bank_account)
|
||||
|
||||
if len(bank_transactions) > 10:
|
||||
@@ -1079,10 +1109,11 @@ def get_linked_payments(
|
||||
from_date: str | date | None = None,
|
||||
to_date: str | date | None = None,
|
||||
filter_by_reference_date: bool | None = None,
|
||||
from_reference_date: bool | None = None,
|
||||
to_reference_date: str | None = None,
|
||||
from_reference_date: str | date | None = None,
|
||||
to_reference_date: str | date | None = None,
|
||||
):
|
||||
# get all matching payments for a bank transaction
|
||||
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
|
||||
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
bank_account = frappe.db.get_values(
|
||||
"Bank Account", transaction.bank_account, ["account", "company"], as_dict=True
|
||||
@@ -1102,6 +1133,23 @@ def get_linked_payments(
|
||||
return subtract_allocations(gl_account, matching)
|
||||
|
||||
|
||||
def validate_date_range(
|
||||
from_date,
|
||||
to_date,
|
||||
filter_by_reference_date=False,
|
||||
from_reference_date=None,
|
||||
to_reference_date=None,
|
||||
):
|
||||
if cint(filter_by_reference_date):
|
||||
from_date, to_date = from_reference_date, to_reference_date
|
||||
message = _("From Reference Date cannot be greater than To Reference Date")
|
||||
else:
|
||||
message = _("From Date cannot be greater than To Date")
|
||||
|
||||
if from_date and to_date and getdate(from_date) > getdate(to_date):
|
||||
frappe.throw(message)
|
||||
|
||||
|
||||
def subtract_allocations(gl_account, vouchers):
|
||||
"Look up & subtract any existing Bank Transaction allocations"
|
||||
copied = []
|
||||
@@ -1138,6 +1186,7 @@ def check_matching(
|
||||
from_reference_date=None,
|
||||
to_reference_date=None,
|
||||
):
|
||||
document_types = document_types or []
|
||||
exact_match = True if "exact_match" in document_types else False
|
||||
|
||||
common_filters = frappe._dict(
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
# See license.txt
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
|
||||
auto_reconcile_vouchers,
|
||||
create_bulk_payment_entry_and_reconcile,
|
||||
create_payment_entry_and_reconcile,
|
||||
get_auto_reconcile_message,
|
||||
get_bank_transactions,
|
||||
get_linked_payments,
|
||||
@@ -16,6 +20,8 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
RATE_METHOD = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_exchange_rate"
|
||||
|
||||
|
||||
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
@@ -131,6 +137,37 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
|
||||
self.assertEqual(names, [])
|
||||
|
||||
def test_get_linked_payments_without_document_types(self):
|
||||
bank_transaction = self.make_bank_transaction(date=today())
|
||||
self.assertEqual(get_linked_payments(bank_transaction.name), [])
|
||||
|
||||
def test_rejects_reversed_date_ranges(self):
|
||||
from_date, to_date = today(), add_days(today(), -1)
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "From Date cannot be greater than To Date"):
|
||||
get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
|
||||
):
|
||||
auto_reconcile_vouchers(
|
||||
self.bank_account,
|
||||
filter_by_reference_date=True,
|
||||
from_reference_date=from_date,
|
||||
to_reference_date=to_date,
|
||||
)
|
||||
|
||||
transaction = self.make_bank_transaction(date=today())
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
|
||||
):
|
||||
get_linked_payments(
|
||||
transaction.name,
|
||||
["payment_entry"],
|
||||
filter_by_reference_date=True,
|
||||
from_reference_date=from_date,
|
||||
to_reference_date=to_date,
|
||||
)
|
||||
|
||||
def test_deposit_matches_amount_received_in_bank_account(self):
|
||||
# money leaves another bank account and lands here minus a charge, so the two sides differ
|
||||
payment = frappe.get_doc(
|
||||
@@ -199,3 +236,117 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIn("1 Transaction Partially Reconciled", singular)
|
||||
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
|
||||
self.assertIn("2 Transactions Partially Reconciled", plural)
|
||||
|
||||
def test_multi_currency_pay_converts_and_balances(self):
|
||||
# withdrawal from an INR bank paying a USD supplier; rate 3.0 makes 100/3 non-exact
|
||||
self.enable_multi_currency_setup()
|
||||
pe = self.reconcile_new_payment(
|
||||
self.make_multi_currency_txn(withdrawal=100),
|
||||
payment_type="Pay",
|
||||
party_type="Supplier",
|
||||
party=self.supplier,
|
||||
party_account=self.creditors_usd,
|
||||
paid_from=self.bank,
|
||||
paid_to=self.creditors_usd,
|
||||
rate=3.0,
|
||||
)
|
||||
self.assertEqual(pe.docstatus, 1) # submits despite the rounding residual
|
||||
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (1.0, 3.0))
|
||||
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33)) # bank side kept, 100/3
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
# Payment Entry auto-books the rounding residual to Exchange Gain/Loss
|
||||
self.assertTrue(pe.deductions[0].is_exchange_gain_loss)
|
||||
self.assertEqual(pe.deductions[0].amount, 0.01) # 100 - 33.33 * 3
|
||||
|
||||
def test_multi_currency_receive_converts_and_balances(self):
|
||||
# deposit into an INR bank from a USD customer; the party side must convert
|
||||
self.enable_multi_currency_setup()
|
||||
pe = self.reconcile_new_payment(
|
||||
self.make_multi_currency_txn(deposit=100),
|
||||
payment_type="Receive",
|
||||
party_type="Customer",
|
||||
party=self.customer,
|
||||
party_account=self.debtors_usd,
|
||||
paid_from=self.debtors_usd,
|
||||
paid_to=self.bank,
|
||||
rate=3.0,
|
||||
)
|
||||
self.assertEqual(pe.docstatus, 1)
|
||||
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (3.0, 1.0))
|
||||
self.assertEqual((pe.received_amount, pe.paid_amount), (100, 33.33)) # bank side kept, 100/3
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
|
||||
def test_multi_currency_bulk_pay_converts_and_balances(self):
|
||||
# the bulk path builds the Payment Entry itself, so it must convert too
|
||||
self.enable_multi_currency_setup()
|
||||
txn = self.make_multi_currency_txn(withdrawal=100)
|
||||
with patch(RATE_METHOD, return_value=3.0):
|
||||
result = create_bulk_payment_entry_and_reconcile(
|
||||
[txn.name], "Supplier", self.supplier, self.creditors_usd
|
||||
)
|
||||
|
||||
pe = frappe.get_doc("Payment Entry", result[0]["payment_entry"].name)
|
||||
self.assertEqual(pe.docstatus, 1)
|
||||
self.assertEqual(pe.target_exchange_rate, 3.0)
|
||||
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33))
|
||||
self.assertEqual(pe.difference_amount, 0)
|
||||
|
||||
def enable_multi_currency_setup(self):
|
||||
# USD party/accounts + a company gain/loss account to absorb rounding residuals
|
||||
self.company_abbr = "_TC"
|
||||
self.create_supplier(supplier_name="_Test Supplier USD", currency="USD")
|
||||
self.create_customer(customer_name="_Test Customer USD", currency="USD")
|
||||
self.create_usd_payable_account()
|
||||
self.create_usd_receivable_account()
|
||||
self.set_party_account("Supplier", self.supplier, self.creditors_usd)
|
||||
if not frappe.db.get_value("Company", self.company, "exchange_gain_loss_account"):
|
||||
frappe.db.set_value(
|
||||
"Company", self.company, "exchange_gain_loss_account", "Exchange Gain/Loss - _TC"
|
||||
)
|
||||
|
||||
def set_party_account(self, party_type, party, account):
|
||||
doc = frappe.get_doc(party_type, party)
|
||||
if not any(row.company == self.company for row in doc.accounts):
|
||||
doc.append("accounts", {"company": self.company, "account": account})
|
||||
doc.save()
|
||||
|
||||
def make_multi_currency_txn(self, withdrawal=0, deposit=0):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": today(),
|
||||
"withdrawal": withdrawal,
|
||||
"deposit": deposit,
|
||||
"bank_account": self.bank_account,
|
||||
"currency": "INR",
|
||||
"reference_number": "TEST-FX-REF",
|
||||
}
|
||||
)
|
||||
.save()
|
||||
.submit()
|
||||
)
|
||||
|
||||
def reconcile_new_payment(
|
||||
self, txn, *, payment_type, party_type, party, party_account, paid_from, paid_to, rate
|
||||
):
|
||||
# mimics the /banking frontend, which sends a hardcoded 1:1 rate
|
||||
payment_entry_doc = {
|
||||
"payment_type": payment_type,
|
||||
"company": self.company,
|
||||
"party_type": party_type,
|
||||
"party": party,
|
||||
"party_account": party_account,
|
||||
"paid_from": paid_from,
|
||||
"paid_to": paid_to,
|
||||
"paid_amount": txn.unallocated_amount,
|
||||
"received_amount": txn.unallocated_amount,
|
||||
"source_exchange_rate": 1,
|
||||
"target_exchange_rate": 1,
|
||||
"posting_date": today(),
|
||||
"reference_no": f"TEST-FX-{payment_type}",
|
||||
"reference_date": today(),
|
||||
}
|
||||
with patch(RATE_METHOD, return_value=rate):
|
||||
result = create_payment_entry_and_reconcile(txn.name, payment_entry_doc)
|
||||
return frappe.get_doc("Payment Entry", result["payment_entry"].name)
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "file-down",
|
||||
"links": [],
|
||||
"modified": "2026-06-19 14:18:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -437,6 +437,11 @@ def get_import_logs(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_bank_statement(**args):
|
||||
# The only caller is the Bank Reconciliation Tool's "Upload Bank Statement" button, whose
|
||||
# callback routes straight into a new Bank Statement Import form — so `create` is exactly the
|
||||
# right to require, and both doctypes are System Manager only, which makes it loser-free.
|
||||
frappe.has_permission("Bank Statement Import", "create", throw=True)
|
||||
|
||||
args = frappe._dict(args)
|
||||
bsi = frappe.new_doc("Bank Statement Import")
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "file-clock",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-09 17:55:25.615942",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2018-10-22 18:19:02.784533",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-transaction",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -277,6 +278,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "arrow-left-right",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-11 20:41:15.124085",
|
||||
|
||||
@@ -11,6 +11,11 @@ from frappe.utils.dateutils import parse_date
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_bank_statement():
|
||||
# Parsing a statement is the first step of creating Bank Transactions from it, so that is the
|
||||
# right to require. Both functions in this file are reached only over HTTP — nothing in the tree
|
||||
# calls either — so there is no caller to break.
|
||||
frappe.has_permission("Bank Transaction", "create", throw=True)
|
||||
|
||||
if getattr(frappe, "uploaded_file", None):
|
||||
with open(frappe.uploaded_file, "rb") as upfile:
|
||||
fcontent = upfile.read()
|
||||
@@ -36,6 +41,12 @@ def upload_bank_statement():
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
# insert()/submit() below already enforce this per document, but only after the per-row loop has
|
||||
# read the Bank Account and its Bank mapping and written an Error Log for every rejected row —
|
||||
# so check once up front rather than failing row by row.
|
||||
frappe.has_permission("Bank Transaction", "create", throw=True)
|
||||
frappe.has_permission("Bank Account", doc=bank_account, throw=True)
|
||||
|
||||
header_map = get_header_mapping(columns, bank_account)
|
||||
|
||||
success = 0
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "zap",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-11 21:36:26.754667",
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "split",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"label": "Generated"
|
||||
}
|
||||
],
|
||||
"icon": "git-branch",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:39.766063",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2016-05-16 11:42:29.632528",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/budgeting",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -330,6 +331,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "piggy-bank",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "lock",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:44.260440",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"description": "Import Chart of Accounts from a csv file",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/chart-of-accounts-importer",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -47,6 +48,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "file-input",
|
||||
"in_create": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"autoname": "field:bank_name",
|
||||
"creation": "2016-05-04 14:35:00.402544",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cheque-print-template",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"settings",
|
||||
@@ -293,6 +294,7 @@
|
||||
"fieldtype": "HTML"
|
||||
}
|
||||
],
|
||||
"icon": "printer",
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2026-06-08 12:10:35.829531",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"description": "Track separate Income and Expense for product verticals or divisions.",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cost-center",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"sb0",
|
||||
@@ -122,7 +123,7 @@
|
||||
"label": "Disabled"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-money",
|
||||
"icon": "chart-pie",
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"autoname": "CC-ALLOC-.#####",
|
||||
"creation": "2022-01-13 20:07:29.871109",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cost_center_allocation",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -65,6 +66,7 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "chart-pie",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -5,7 +5,10 @@ frappe.ui.form.on("Coupon Code", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("pricing_rule", function () {
|
||||
return {
|
||||
filters: [["Pricing Rule", "coupon_code_based", "=", "1"]],
|
||||
filters: {
|
||||
coupon_code_based: 1,
|
||||
disable: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"creation": "2018-01-22 14:34:39.701832",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Other",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/coupon-code",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -124,6 +125,7 @@
|
||||
"label": "From External Ecomm Platform"
|
||||
}
|
||||
],
|
||||
"icon": "ticket-percent",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:41.010871",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -42,7 +42,23 @@ class CouponCode(Document):
|
||||
self.coupon_code = frappe.generate_hash()[:10].upper()
|
||||
|
||||
def validate(self):
|
||||
self.validate_from_to_dates("valid_from", "valid_upto")
|
||||
self.validate_pricing_rule()
|
||||
|
||||
if self.coupon_type == "Gift Card":
|
||||
self.maximum_use = 1
|
||||
if not self.customer:
|
||||
frappe.throw(_("Please select the customer."))
|
||||
|
||||
def validate_pricing_rule(self):
|
||||
if not self.pricing_rule or self.from_external_ecomm_platform:
|
||||
return
|
||||
|
||||
# Allow existing coupons to be updated after their pricing rule is disabled.
|
||||
if not (
|
||||
self.has_value_changed("pricing_rule") or self.has_value_changed("from_external_ecomm_platform")
|
||||
):
|
||||
return
|
||||
|
||||
if frappe.db.get_value("Pricing Rule", self.pricing_rule, "disable"):
|
||||
frappe.throw(_("Pricing Rule {0} is disabled").format(frappe.bold(self.pricing_rule)))
|
||||
|
||||
@@ -112,6 +112,43 @@ class TestCouponCode(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
test_create_test_data()
|
||||
|
||||
def test_disabled_pricing_rule_validation(self):
|
||||
coupon = frappe.get_doc("Coupon Code", "SAVE30")
|
||||
rule = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
|
||||
rule.disable = 1
|
||||
rule.save()
|
||||
|
||||
with self.subTest("new coupon cannot select a disabled rule"):
|
||||
new_coupon = frappe.copy_doc(coupon)
|
||||
new_coupon.coupon_name = "Festival Savings"
|
||||
new_coupon.coupon_code = "FESTSAVE"
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
new_coupon.insert()
|
||||
|
||||
with self.subTest("existing coupon can retain a disabled rule"):
|
||||
coupon.description = "Offer paused"
|
||||
coupon.save()
|
||||
coupon.reload()
|
||||
self.assertEqual(coupon.description, "Offer paused")
|
||||
self.assertEqual(coupon.pricing_rule, rule.name)
|
||||
|
||||
with self.subTest("existing coupon cannot switch to a disabled rule"):
|
||||
disabled_rule = frappe.copy_doc(rule)
|
||||
disabled_rule.insert()
|
||||
coupon.reload()
|
||||
coupon.pricing_rule = disabled_rule.name
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
|
||||
coupon.save()
|
||||
coupon.reload()
|
||||
self.assertEqual(coupon.pricing_rule, rule.name)
|
||||
|
||||
def test_cannot_save_coupon_with_reversed_validity_dates(self):
|
||||
coupon = frappe.get_doc("Coupon Code", "SAVE30")
|
||||
coupon.valid_from = "2026-09-17"
|
||||
coupon.valid_upto = "2026-09-02"
|
||||
with self.assertRaises(frappe.exceptions.InvalidDates):
|
||||
coupon.save()
|
||||
|
||||
def test_sales_order_with_coupon_code(self):
|
||||
frappe.db.set_value("Coupon Code", "SAVE30", "used", 0)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"actions": [],
|
||||
"creation": "2022-01-10 13:03:26.237081",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/currency-exchange-settings",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -101,6 +102,7 @@
|
||||
"label": "Use HTTP Protocol"
|
||||
}
|
||||
],
|
||||
"icon": "refresh-cw",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2019-07-05 16:34:31.013238",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/dunning",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"naming_series",
|
||||
@@ -398,6 +399,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "bell-ring",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-30 23:18:04.712528",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "bell",
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "Dunning",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"autoname": "ACC-ERR-.YYYY.-.#####",
|
||||
"creation": "2018-04-13 18:25:55.943587",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/exchange-rate-revaluation",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -107,6 +108,7 @@
|
||||
"precision": "9"
|
||||
}
|
||||
],
|
||||
"icon": "arrow-right-left",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:42.951164",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"creation": "2018-04-13 17:42:43.252224",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/finance-book",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -18,7 +19,7 @@
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-book",
|
||||
"icon": "book",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:42.386104",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import ast
|
||||
import json
|
||||
import math
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cache, reduce
|
||||
@@ -29,6 +28,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_templat
|
||||
FinancialReportTemplate,
|
||||
)
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FORMULA_FUNCTIONS,
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
DependencyValidator,
|
||||
@@ -1328,26 +1328,14 @@ class FormulaCalculator:
|
||||
self.precision = get_currency_precision()
|
||||
self.validator = CalculationFormulaValidator(set(row_data.keys()))
|
||||
|
||||
self.math_functions = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": math.sqrt,
|
||||
"pow": math.pow,
|
||||
"ceil": math.ceil,
|
||||
"floor": math.floor,
|
||||
}
|
||||
|
||||
def evaluate_formula(self, report_row: dict[str, Any]) -> list[float]:
|
||||
validation_result = self.validator.validate(report_row)
|
||||
formula = report_row.calculation_formula
|
||||
formula = (report_row.calculation_formula or "").strip()
|
||||
negation_factor = -1 if report_row.reverse_sign else 1
|
||||
|
||||
if validation_result.issues:
|
||||
# TODO: Throw?
|
||||
messages = "<br><br>".join(issue.message for issue in validation_result.issues)
|
||||
messages = "<br><br>".join(str(issue) for issue in validation_result.issues)
|
||||
frappe.log_error(f"Formula validation errors found:\n{messages}")
|
||||
return [0.0] * len(self.period_list)
|
||||
|
||||
@@ -1362,7 +1350,7 @@ class FormulaCalculator:
|
||||
# TODO: consistent error handling
|
||||
try:
|
||||
context = self._build_context(period_index)
|
||||
result = frappe.safe_eval(formula, context)
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
return flt(result * negation_factor, self.precision)
|
||||
|
||||
except ZeroDivisionError:
|
||||
@@ -1383,7 +1371,7 @@ class FormulaCalculator:
|
||||
context[code] = 0.0
|
||||
|
||||
# math functions
|
||||
context.update(self.math_functions)
|
||||
context.update(FORMULA_FUNCTIONS)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "field:template_name",
|
||||
"creation": "2025-08-02 04:44:15.184541",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/financial-report-template",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"template_name",
|
||||
@@ -64,6 +65,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "file-spreadsheet",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 01:04:05.797161",
|
||||
|
||||
@@ -34,6 +34,13 @@ class FinancialReportTemplate(Document):
|
||||
def before_validate(self):
|
||||
self.clear_hidden_fields()
|
||||
|
||||
for row in self.rows:
|
||||
if row.reference_code:
|
||||
row.reference_code = row.reference_code.strip()
|
||||
|
||||
if row.calculation_formula:
|
||||
row.calculation_formula = row.calculation_formula.strip()
|
||||
|
||||
def clear_hidden_fields(self):
|
||||
style_data_sources = {"Blank Line", "Column Break", "Section Break"}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import json
|
||||
import keyword
|
||||
import math
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
@@ -10,6 +12,19 @@ from typing import Any
|
||||
import frappe
|
||||
from frappe import _, is_whitelisted
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
from frappe.utils import escape_html
|
||||
|
||||
FORMULA_FUNCTIONS = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": math.sqrt,
|
||||
"pow": math.pow,
|
||||
"ceil": math.ceil,
|
||||
"floor": math.floor,
|
||||
}
|
||||
|
||||
|
||||
def get_valid_api_method(api_path: str):
|
||||
@@ -89,8 +104,9 @@ class ValidationResult:
|
||||
self.warnings.append(issue)
|
||||
|
||||
def notify_user(self) -> None:
|
||||
warnings = "<br><br>".join(str(w) for w in self.warnings if w)
|
||||
errors = "<br><br>".join(str(e) for e in self.issues if e)
|
||||
# messages quote user input back, and both are rendered as HTML
|
||||
warnings = "<br><br>".join(escape_html(str(w)) for w in self.warnings if w)
|
||||
errors = "<br><br>".join(escape_html(str(e)) for e in self.issues if e)
|
||||
|
||||
if warnings:
|
||||
frappe.msgprint(warnings, title=_("Warnings"), indicator="orange")
|
||||
@@ -147,18 +163,27 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.reference_code:
|
||||
continue
|
||||
|
||||
ref_code = row.reference_code.strip()
|
||||
ref_code = row.reference_code
|
||||
|
||||
# Check format
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
|
||||
# a line reference is used as a name in formulas, so it must be a usable one
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", ref_code):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_(
|
||||
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
|
||||
"Invalid line reference format: '{0}'. Must start with a letter and contain only letters, numbers and underscores"
|
||||
).format(ref_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
elif keyword.iskeyword(ref_code) or ref_code in FORMULA_FUNCTIONS:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("'{0}' is a reserved name and cannot be used as a line reference").format(
|
||||
ref_code
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
|
||||
# Check uniqueness
|
||||
if ref_code in used_codes:
|
||||
@@ -208,12 +233,7 @@ class DependencyValidator(Validator):
|
||||
self.dependencies = self._build_dependency_graph()
|
||||
|
||||
def validate(self, context=None) -> ValidationResult:
|
||||
result = ValidationResult()
|
||||
|
||||
result.merge(self._validate_circular_dependencies())
|
||||
result.merge(self._validate_missing_dependencies())
|
||||
|
||||
return result
|
||||
return self._validate_circular_dependencies()
|
||||
|
||||
def _build_dependency_graph(self) -> dict[str, list[str]]:
|
||||
graph = {}
|
||||
@@ -280,31 +300,6 @@ class DependencyValidator(Validator):
|
||||
|
||||
return result
|
||||
|
||||
def _validate_missing_dependencies(self) -> ValidationResult:
|
||||
available = {row.reference_code for row in self.template.rows if row.reference_code}
|
||||
result = ValidationResult()
|
||||
|
||||
for ref_code, deps in self.dependencies.items():
|
||||
undefined = [d for d in deps if d not in available]
|
||||
if undefined:
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Line references undefined in {0}: {1}").format(
|
||||
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
|
||||
),
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _get_row_idx(self, reference_code: str) -> int | None:
|
||||
for row in self.template.rows:
|
||||
if row.reference_code == reference_code:
|
||||
return row.idx
|
||||
return None
|
||||
|
||||
|
||||
class CalculationFormulaValidator(Validator):
|
||||
"""Validates calculation formulas used in Calculated Amount rows"""
|
||||
@@ -320,7 +315,6 @@ class CalculationFormulaValidator(Validator):
|
||||
return result
|
||||
|
||||
formula = self._preprocess_formula(row.calculation_formula)
|
||||
row.calculation_formula = formula
|
||||
|
||||
# Check parentheses
|
||||
if not self._are_parentheses_balanced(formula):
|
||||
@@ -368,25 +362,15 @@ class CalculationFormulaValidator(Validator):
|
||||
def _test_formula_evaluation(self, formula: str, available_codes: list[str]) -> str | None:
|
||||
try:
|
||||
context = {code: 1.0 for code in available_codes}
|
||||
context.update(
|
||||
{
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sqrt": lambda x: x**0.5,
|
||||
"pow": pow,
|
||||
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
|
||||
"floor": int,
|
||||
}
|
||||
)
|
||||
context.update(FORMULA_FUNCTIONS)
|
||||
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
|
||||
if not isinstance(result, (int, float)): # noqa: UP038
|
||||
if not isinstance(result, (int | float)):
|
||||
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
|
||||
|
||||
return None
|
||||
except ZeroDivisionError:
|
||||
return None
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
@@ -462,13 +446,14 @@ class AccountFilterValidator(Validator):
|
||||
return _("Field and operator must be strings")
|
||||
|
||||
if field not in account_fields:
|
||||
# escape: `field` is caller-supplied and this message renders as HTML
|
||||
return _("Field '{0}' is not a valid Account field").format(frappe.utils.escape_html(field))
|
||||
return _("Field '{0}' is not a valid Account field").format(field)
|
||||
|
||||
if operator.casefold() not in OPERATOR_MAP:
|
||||
normalized_operator = operator.casefold()
|
||||
|
||||
if normalized_operator not in OPERATOR_MAP:
|
||||
return _("Invalid operator '{0}'").format(operator)
|
||||
|
||||
if operator in ["in", "not in"] and not isinstance(value, list):
|
||||
if normalized_operator in ["in", "not in"] and not isinstance(value, list):
|
||||
return _("Operator '{0}' requires a list value").format(operator)
|
||||
|
||||
# logical condition: {"and": [condition1, condition2]}
|
||||
|
||||
@@ -5,8 +5,11 @@ import frappe
|
||||
from frappe.tests.utils import whitelist_for_tests
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FORMULA_FUNCTIONS,
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
FormulaValidator,
|
||||
TemplateStructureValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -189,8 +192,13 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
|
||||
def test_error_message_labels_and_escapes_field(self):
|
||||
validator = AccountFilterValidator()
|
||||
result = validator.validate_filter(self._row('["<script>", "=", "x"]'))
|
||||
message = str(result.issues[0])
|
||||
self.assertIn("[Account Filter]", message)
|
||||
self.assertIn("[Account Filter]", str(result.issues[0]))
|
||||
|
||||
# escaping happens where the message is rendered, not where it is built
|
||||
frappe.clear_messages()
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
result.notify_user()
|
||||
message = frappe.get_message_log()[-1]["message"]
|
||||
self.assertIn("<script>", message)
|
||||
self.assertNotIn("<script>", message)
|
||||
|
||||
@@ -248,3 +256,149 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
|
||||
pluck="name",
|
||||
)
|
||||
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))
|
||||
|
||||
|
||||
class TestFormulaEnvironment(FinancialReportTemplateTestCase):
|
||||
"""Validator and engine must evaluate a formula in the same environment."""
|
||||
|
||||
@staticmethod
|
||||
def _calc(row_data):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FormulaCalculator,
|
||||
)
|
||||
|
||||
return FormulaCalculator(row_data, [{"key": "p1"}])
|
||||
|
||||
@staticmethod
|
||||
def _row(formula):
|
||||
return frappe._dict(
|
||||
calculation_formula=formula,
|
||||
idx=1,
|
||||
reverse_sign=0,
|
||||
data_source="Calculated Amount",
|
||||
reference_code="X",
|
||||
)
|
||||
|
||||
def test_engine_keeps_reference_codes_named_like_builtins(self):
|
||||
# "int" and "long" are whitelisted safe_eval globals; the row values must win
|
||||
calc = self._calc({"int": [500.0], "long": [2000.0]})
|
||||
self.assertEqual(calc.evaluate_formula(self._row("int + long"))[0], 2500.0)
|
||||
|
||||
def test_validator_keeps_reference_codes_named_like_builtins(self):
|
||||
validator = CalculationFormulaValidator({"int", "long"})
|
||||
self.assertTrue(validator.validate(self._row("int + long")).is_valid)
|
||||
|
||||
def test_engine_uses_the_shared_function_list(self):
|
||||
context = self._calc({"A": [1.0]})._build_context(0)
|
||||
for name, function in FORMULA_FUNCTIONS.items():
|
||||
self.assertIs(context[name], function)
|
||||
|
||||
def test_rounding_matches_math_module(self):
|
||||
calc = self._calc({"A": [1.0]})
|
||||
self.assertEqual(calc.evaluate_formula(self._row("floor(-2.5)"))[0], -3.0)
|
||||
self.assertEqual(calc.evaluate_formula(self._row("ceil(-2.5)"))[0], -2.0)
|
||||
|
||||
|
||||
class TestCalculationFormula(FinancialReportTemplateTestCase):
|
||||
"""Formulas are test-evaluated with dummy values before a template can be saved."""
|
||||
|
||||
@staticmethod
|
||||
def _validate(formula, codes=("A", "B", "C")):
|
||||
row = frappe._dict(
|
||||
calculation_formula=formula, idx=1, data_source="Calculated Amount", reference_code="X"
|
||||
)
|
||||
return CalculationFormulaValidator(set(codes)).validate(row)
|
||||
|
||||
def test_division_by_zero_is_not_a_validation_error(self):
|
||||
# the dummy values are all 1.0, so a denominator can only be zero by accident;
|
||||
# the engine tolerates real division by zero at run time
|
||||
self.assertTrue(self._validate("A / (B - C)").is_valid)
|
||||
self.assertTrue(self._validate("(A - B) / (A - C)").is_valid)
|
||||
self.assertTrue(self._validate("ROM / (CAS + FDE - ROM)", ("ROM", "CAS", "FDE")).is_valid)
|
||||
self.assertTrue(self._validate("A / 0").is_valid)
|
||||
|
||||
def test_broken_formulas_are_rejected(self):
|
||||
self.assertFalse(self._validate("A +").is_valid)
|
||||
self.assertFalse(self._validate("NOPE * 2").is_valid)
|
||||
self.assertFalse(self._validate("'text'").is_valid)
|
||||
|
||||
|
||||
class TestFilterOperatorCase(FinancialReportTemplateTestCase):
|
||||
"""Operators are matched case-insensitively, so their value checks must be too."""
|
||||
|
||||
@staticmethod
|
||||
def _row(formula):
|
||||
return frappe._dict(calculation_formula=formula, idx=1)
|
||||
|
||||
def test_uppercase_in_requires_a_list_value(self):
|
||||
validator = AccountFilterValidator()
|
||||
self.assertFalse(validator.validate_filter(self._row('["root_type", "IN", "Income"]')).is_valid)
|
||||
self.assertFalse(validator.validate_filter(self._row('["root_type", "NOT IN", "Income"]')).is_valid)
|
||||
|
||||
def test_uppercase_in_accepts_a_list_value(self):
|
||||
validator = AccountFilterValidator()
|
||||
self.assertTrue(validator.validate_filter(self._row('["root_type", "IN", ["Income"]]')).is_valid)
|
||||
|
||||
|
||||
class TestLineReferenceNames(FinancialReportTemplateTestCase):
|
||||
"""A line reference becomes a name in formulas, so it must be usable as one."""
|
||||
|
||||
@staticmethod
|
||||
def _validate(code):
|
||||
template = frappe._dict(rows=[frappe._dict(reference_code=code, idx=1, data_source="Blank Line")])
|
||||
return TemplateStructureValidator()._validate_reference_codes(template)
|
||||
|
||||
def test_plain_codes_are_accepted(self):
|
||||
for code in ("REV", "CA100", "cash_flow_2"):
|
||||
self.assertTrue(self._validate(code).is_valid, code)
|
||||
|
||||
def test_hyphen_is_rejected(self):
|
||||
# "-" reads as subtraction in a formula and is not a valid Python name
|
||||
self.assertFalse(self._validate("REV-COGS").is_valid)
|
||||
|
||||
def test_python_keyword_is_rejected(self):
|
||||
for code in ("if", "None", "class"):
|
||||
self.assertFalse(self._validate(code).is_valid, code)
|
||||
|
||||
def test_formula_function_name_is_rejected(self):
|
||||
# these would be overwritten by the function of the same name
|
||||
for code in ("sum", "round", "abs"):
|
||||
self.assertFalse(self._validate(code).is_valid, code)
|
||||
|
||||
def test_surrounding_spaces_are_normalised_before_validation(self):
|
||||
template = frappe.new_doc("Financial Report Template")
|
||||
template.template_name = "Spaces"
|
||||
template.append("rows", {"reference_code": " REV ", "data_source": "Blank Line"})
|
||||
template.append(
|
||||
"rows",
|
||||
{
|
||||
"reference_code": "X",
|
||||
"data_source": "Calculated Amount",
|
||||
"calculation_formula": " REV * 2 ",
|
||||
},
|
||||
)
|
||||
template.before_validate()
|
||||
self.assertEqual(template.rows[0].reference_code, "REV")
|
||||
self.assertEqual(template.rows[1].calculation_formula, "REV * 2")
|
||||
|
||||
def test_validation_does_not_modify_the_row(self):
|
||||
row = frappe._dict(
|
||||
calculation_formula=" REV * 2 ",
|
||||
idx=1,
|
||||
data_source="Calculated Amount",
|
||||
reference_code="X",
|
||||
)
|
||||
CalculationFormulaValidator({"REV", "X"}).validate(row)
|
||||
self.assertEqual(row.calculation_formula, " REV * 2 ")
|
||||
|
||||
def test_invalid_reference_code_is_escaped(self):
|
||||
# this message fires when the code fails the format check, so it can hold anything
|
||||
template = frappe._dict(rows=[frappe._dict(reference_code="<img src=x onerror=alert(1)>", idx=1)])
|
||||
result = TemplateStructureValidator()._validate_reference_codes(template)
|
||||
|
||||
frappe.clear_messages()
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
result.notify_user()
|
||||
message = frappe.get_message_log()[-1]["message"]
|
||||
self.assertIn("<img", message)
|
||||
self.assertNotIn("<img", message)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"description": "Represents a Financial Year. All accounting entries and other major transactions are tracked against the Fiscal Year.",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/fiscal-year",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"year",
|
||||
@@ -79,7 +80,7 @@
|
||||
"set_only_once": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-calendar",
|
||||
"icon": "calendar",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:42.509102",
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
"precision": "9"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-list",
|
||||
"icon": "book-open",
|
||||
"idx": 1,
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"autoname": "ACC-INV-DISC-.YYYY.-.#####",
|
||||
"creation": "2019-03-07 12:01:56.296952",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/invoice_discounting",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -169,6 +170,7 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "ticket-percent",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-09-09 17:04:59.512294",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"creation": "2022-01-19 01:09:13.297137",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/item-tax-template",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -56,6 +57,7 @@
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"icon": "circle-percent",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:43.571355",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"creation": "2022-01-25 10:29:58.717206",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/journal-entry",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"entry_type_and_date",
|
||||
@@ -661,7 +662,7 @@
|
||||
"label": "Custom Remark"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-file-text",
|
||||
"icon": "notebook-pen",
|
||||
"idx": 176,
|
||||
"is_submittable": 1,
|
||||
"links": [
|
||||
|
||||
@@ -1023,6 +1023,11 @@ def get_default_bank_cash_account(
|
||||
) -> dict:
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
|
||||
|
||||
# the company is the scope being authorised, and doc= brings User Permissions to bear. `select`,
|
||||
# not `read`: this also runs server-side from get_payment_entry, and Auditor/HR User/Desk User
|
||||
# hold only the select row on Company
|
||||
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
|
||||
|
||||
if mode_of_payment:
|
||||
account = get_bank_cash_account(mode_of_payment, company).get("account")
|
||||
|
||||
@@ -1051,6 +1056,11 @@ def get_default_bank_cash_account(
|
||||
account = account_list[0].name
|
||||
|
||||
if account:
|
||||
# `account` may be named by the caller outright, so authorise the account actually being
|
||||
# described. get_balance_on() checks this too, but only on the branch that reads a balance,
|
||||
# and `fetch_balance` is a caller-supplied argument.
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
account_details = frappe.get_cached_value(
|
||||
"Account", account, ["account_currency", "account_type"], as_dict=1
|
||||
)
|
||||
@@ -1080,30 +1090,40 @@ def get_against_jv(
|
||||
if not frappe.db.has_column("Journal Entry", searchfield):
|
||||
return []
|
||||
|
||||
JournalEntry = frappe.qb.DocType("Journal Entry")
|
||||
JournalEntryAccount = frappe.qb.DocType("Journal Entry Account")
|
||||
account = filters.get("account")
|
||||
party = filters.get("party")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(JournalEntry)
|
||||
.join(JournalEntryAccount)
|
||||
.on(JournalEntryAccount.parent == JournalEntry.name)
|
||||
.select(JournalEntry.name, JournalEntry.posting_date, JournalEntry.remark)
|
||||
.where(JournalEntryAccount.account == filters.get("account"))
|
||||
.where(JournalEntryAccount.reference_type.isnull() | (JournalEntryAccount.reference_type == ""))
|
||||
.where(JournalEntry.docstatus == 1)
|
||||
.where(JournalEntry[searchfield].like(f"%{txt}%"))
|
||||
.orderby(JournalEntry.name, order=frappe.qb.desc)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
# each names one value. A list would be read as a filter operator below and widen the search
|
||||
# past what the caller named.
|
||||
for value in (account, party):
|
||||
if value and not isinstance(value, str):
|
||||
frappe.throw(_("Invalid filter"), frappe.PermissionError)
|
||||
|
||||
# get_list applies the permission query conditions; the child-table filter resolves the check to `read`
|
||||
je_filters = [
|
||||
["docstatus", "=", 1],
|
||||
[searchfield, "like", f"%{txt}%"],
|
||||
["Journal Entry Account", "account", "=", account],
|
||||
["Journal Entry Account", "reference_type", "is", "not set"],
|
||||
]
|
||||
je_filters.append(
|
||||
["Journal Entry Account", "party", "=", party]
|
||||
if party
|
||||
else ["Journal Entry Account", "party", "is", "not set"]
|
||||
)
|
||||
|
||||
party = filters.get("party")
|
||||
if party:
|
||||
query = query.where(JournalEntryAccount.party == party)
|
||||
else:
|
||||
query = query.where(JournalEntryAccount.party.isnull() | (JournalEntryAccount.party == ""))
|
||||
|
||||
return query.run()
|
||||
return frappe.get_list(
|
||||
"Journal Entry",
|
||||
filters=je_filters,
|
||||
fields=["name", "posting_date", "remark"],
|
||||
order_by="name desc",
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
# one row per entry, not per matching account row. group_by rather than distinct: frappe
|
||||
# drops ORDER BY from a distinct query on postgres, which would lose the ordering above.
|
||||
group_by="name",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"creation": "2020-04-09 01:32:51.332301",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/journal-entry-template",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -85,6 +86,7 @@
|
||||
"label": "Multi Currency"
|
||||
}
|
||||
],
|
||||
"icon": "notebook-text",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:58.814734",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"label": "General and Payment Ledger mismatch"
|
||||
}
|
||||
],
|
||||
"icon": "heart-pulse",
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "activity",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "merge",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:02.198009",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"label": "Discretionary Reason"
|
||||
}
|
||||
],
|
||||
"icon": "star",
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2024-07-01 08:51:13.927009",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "field:loyalty_program_name",
|
||||
"creation": "2018-01-23 06:23:05.731431",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/loyalty-program",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -153,6 +154,7 @@
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"icon": "gift",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:44.144864",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -129,6 +129,11 @@ def get_loyalty_program_details(
|
||||
silent: bool = False,
|
||||
include_expired_entry: bool = False,
|
||||
):
|
||||
# Same guard as get_loyalty_program_details_with_points above: the customer is what the caller
|
||||
# is entitled to, not the programme. A check on Loyalty Program itself would be read-only to
|
||||
# System Manager and would deny every role that actually fills in the two calling forms.
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
lp_details = frappe._dict()
|
||||
|
||||
if not loyalty_program:
|
||||
@@ -150,6 +155,13 @@ def get_loyalty_program_details(
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_redeemption_factor(loyalty_program: str | None = None, customer: str | None = None):
|
||||
# both call sites send only `loyalty_program`, so the calling form is the boundary; the customer branch stays guarded
|
||||
if not (frappe.has_permission("Sales Invoice") or frappe.has_permission("POS Invoice")):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
if customer:
|
||||
frappe.has_permission("Customer", doc=customer, throw=True)
|
||||
|
||||
customer_loyalty_program = None
|
||||
if not loyalty_program:
|
||||
customer_loyalty_program = frappe.db.get_value("Customer", customer, "loyalty_program")
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"creation": "2012-12-04 17:49:20",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/mode-of-payment",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"mode_of_payment",
|
||||
@@ -44,7 +45,7 @@
|
||||
"label": "Enabled"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-credit-card",
|
||||
"icon": "wallet",
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"options": "Monthly Distribution Percentage"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-bar-chart",
|
||||
"icon": "chart-bar",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:44.908490",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"allow_copy": 1,
|
||||
"creation": "2017-08-29 02:22:54.947711",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/opening-invoice-creation-tool",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -88,6 +89,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "file-plus",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-30 23:18:48.691227",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"options": "secondary_role"
|
||||
}
|
||||
],
|
||||
"icon": "link",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:08.607170",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2016-06-01 14:38:51.012597",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-entry",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type_of_payment",
|
||||
@@ -784,6 +785,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "banknote",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"icon": "credit-card",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-14 16:49:55.210352",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"icon": "book-open",
|
||||
"is_submittable": 1,
|
||||
"field_order": [
|
||||
"posting_date",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2018-07-20 16:43:08.505978",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-order",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -107,6 +108,7 @@
|
||||
"label": "Account"
|
||||
}
|
||||
],
|
||||
"icon": "send",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:10.186727",
|
||||
|
||||
@@ -57,9 +57,27 @@ class PaymentOrder(Document):
|
||||
frappe.db.set_value(self.payment_order_type, d.get(ref_doc_field), ref_field, status)
|
||||
|
||||
|
||||
def _readable_payment_order(filters: dict) -> str | None:
|
||||
"""Authorise the parent before reading its rows.
|
||||
|
||||
A child table carries no permissions of its own, so a read of it has to be authorised on the
|
||||
Payment Order the rows belong to.
|
||||
"""
|
||||
parent = filters.get("parent")
|
||||
if not parent or not frappe.db.exists("Payment Order", parent):
|
||||
return None
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm("Payment Order") else "read"
|
||||
frappe.has_permission("Payment Order", ptype, doc=parent, throw=True)
|
||||
return parent
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
if not _readable_payment_order(filters):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
|
||||
@@ -74,6 +92,9 @@ def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
if not _readable_payment_order(filters):
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"allow_copy": 1,
|
||||
"creation": "2014-07-09 12:04:51.681583",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-reconciliation",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"company",
|
||||
@@ -235,7 +236,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"icon": "icon-resize-horizontal",
|
||||
"icon": "arrow-left-right",
|
||||
"is_virtual": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2015-12-15 22:23:24.745065",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-request",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"payment_request_type",
|
||||
@@ -474,6 +475,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "hand-coins",
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"autoname": "field:payment_term_name",
|
||||
"creation": "2017-08-10 15:24:54.876365",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-terms",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -115,6 +116,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "calendar-clock",
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:45.693762",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"autoname": "field:template_name",
|
||||
"creation": "2017-08-10 15:34:28.058054",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-terms-template",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -34,6 +35,7 @@
|
||||
"label": "Allocate Payment Based On Payment Terms"
|
||||
}
|
||||
],
|
||||
"icon": "calendar-clock",
|
||||
"links": [],
|
||||
"modified": "2024-04-07 11:26:42.021585",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "anchor",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "ACC-PCV-.YYYY.-.#####",
|
||||
"creation": "2013-01-10 16:34:07",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/period-closing-voucher",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"transaction_date",
|
||||
@@ -110,7 +111,7 @@
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-file-text",
|
||||
"icon": "folder-lock",
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -254,6 +254,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "book-lock",
|
||||
"is_submittable": 1,
|
||||
"links": [
|
||||
{
|
||||
|
||||
@@ -256,6 +256,13 @@ class POSClosingEntry(StatusUpdater):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_cashiers(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
pos_profile = filters.get("parent")
|
||||
if not pos_profile or not frappe.db.exists("POS Profile", pos_profile):
|
||||
return []
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm("POS Profile") else "read"
|
||||
frappe.has_permission("POS Profile", ptype, doc=pos_profile, throw=True)
|
||||
|
||||
cashiers_list = frappe.get_all("POS Profile User", filters=filters, fields=["user"], as_list=1)
|
||||
return [c for c in cashiers_list]
|
||||
|
||||
|
||||
@@ -1640,7 +1640,7 @@
|
||||
"print_hide": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-file-text",
|
||||
"icon": "receipt-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:45.029925",
|
||||
|
||||
@@ -909,6 +909,30 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
# The POS Profile is what entitles a caller to POS stock figures, and it is the only boundary
|
||||
# that fits: `Item` read and `Bin` read both exclude Accounts Manager, `Item` select is granted
|
||||
# to every desk user by `Desk User`, and `POS Invoice` read is granted to `All`.
|
||||
frappe.has_permission("POS Profile", throw=True)
|
||||
|
||||
# and keep a company-restricted caller inside their own companies, which costs nobody who has
|
||||
# no Company User Permission
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, "POS Profile")
|
||||
if allowed_companies:
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
if company and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
# the caller picks the warehouse when allow_warehouse_change is set, and the company check above
|
||||
# does not narrow within a company; costs nobody who has no Warehouse User Permission
|
||||
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
|
||||
|
||||
if warehouse_permissions := get_user_permissions(frappe.session.user).get("Warehouse"):
|
||||
allowed_warehouses = get_allowed_docs_for_doctype(warehouse_permissions, "POS Invoice")
|
||||
if allowed_warehouses and warehouse not in allowed_warehouses:
|
||||
frappe.throw(_("Not permitted for {0}").format(warehouse), frappe.PermissionError)
|
||||
|
||||
if frappe.db.get_value("Item", item_code, "is_stock_item"):
|
||||
is_stock_item = True
|
||||
bin_qty = get_bin_qty(item_code, warehouse)
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"icon": "merge",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"icon": "folder-open",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:16.034296",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"autoname": "Prompt",
|
||||
"creation": "2013-05-24 12:15:51",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/pos-profile",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"company",
|
||||
@@ -557,7 +558,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "icon-cog",
|
||||
"icon": "monitor",
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 0,
|
||||
"icon": "settings",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-16 13:28:19.677217",
|
||||
|
||||
@@ -108,7 +108,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
</td></tr>
|
||||
</table>`;
|
||||
|
||||
frm.set_df_property("pricing_rule_help", "options", help_content);
|
||||
frm.get_field("pricing_rule_help").html(help_content);
|
||||
frm.events.set_options_for_applicable_for(frm);
|
||||
frm.trigger("toggle_reqd_apply_on");
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2014-02-21 15:02:51",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/pricing-rule",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"applicability_section",
|
||||
@@ -654,7 +655,7 @@
|
||||
"label": "Don't Enforce Free Item Qty"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-gift",
|
||||
"icon": "badge-percent",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-17 12:24:07.553505",
|
||||
|
||||
@@ -12,6 +12,29 @@ from frappe import _, throw
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
# the transactions the pricing engine is called for, from transaction.js and the POS
|
||||
PRICING_TRANSACTION_DOCTYPES = frozenset(
|
||||
{
|
||||
"Quotation",
|
||||
"Sales Order",
|
||||
"Delivery Note",
|
||||
"Sales Invoice",
|
||||
"POS Invoice",
|
||||
"Supplier Quotation",
|
||||
"Purchase Order",
|
||||
"Purchase Receipt",
|
||||
"Purchase Invoice",
|
||||
"Material Request",
|
||||
# these three also extend a controller that calls the pricing engine: BOM and BOM Creator
|
||||
# through TransactionController, Request for Quotation through BuyingController
|
||||
"BOM",
|
||||
"BOM Creator",
|
||||
"Request for Quotation",
|
||||
# no client sends this one, but set_transaction_type below still branches on it
|
||||
"Opportunity",
|
||||
}
|
||||
)
|
||||
|
||||
apply_on_dict = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}
|
||||
|
||||
other_fields = ["other_item_code", "other_item_group", "other_brand"]
|
||||
@@ -366,6 +389,30 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
|
||||
|
||||
args = frappe._dict(args)
|
||||
|
||||
# `args` is caller supplied, and what comes back is pricing: matched Pricing Rules, discounts
|
||||
# and rates. The transaction being priced is what decides who may price it, so authorise that
|
||||
# — and the document itself where the caller named an existing one, so User Permissions apply.
|
||||
# an allow-list, not just a type check: `doctype` is caller-chosen, and any doctype the caller can
|
||||
# read would otherwise satisfy has_permission below while the pricing engine still ran
|
||||
transaction_doctype = args.get("doctype")
|
||||
if transaction_doctype not in PRICING_TRANSACTION_DOCTYPES:
|
||||
frappe.throw(_("Invalid doctype"), frappe.PermissionError)
|
||||
|
||||
transaction_name = args.get("name")
|
||||
if not isinstance(transaction_name, str) or not frappe.db.exists(transaction_doctype, transaction_name):
|
||||
transaction_name = None
|
||||
|
||||
frappe.has_permission(transaction_doctype, doc=transaction_name, throw=True)
|
||||
|
||||
# scope by the caller's own Company restrictions, not a Company read: several roles that fill these forms hold none
|
||||
company = args.get("company")
|
||||
if company:
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
|
||||
|
||||
allowed_companies = get_allowed_companies(frappe.session.user, transaction_doctype)
|
||||
if allowed_companies and company not in allowed_companies:
|
||||
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
|
||||
|
||||
set_transaction_type(args)
|
||||
|
||||
# list of dictionaries
|
||||
@@ -725,14 +772,18 @@ def set_transaction_type(pricing_ctx: frappe._dict) -> None:
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_item_uoms(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
items = [filters.get("value")]
|
||||
if filters.get("apply_on") != "Item Code":
|
||||
field = frappe.scrub(filters.get("apply_on"))
|
||||
items = [d.name for d in frappe.db.get_all("Item", filters={field: filters.get("value")})]
|
||||
if filters.get("apply_on") == "Item Code":
|
||||
item_filters = [["name", "=", filters.get("value")]]
|
||||
else:
|
||||
item_filters = [[frappe.scrub(filters.get("apply_on")), "=", filters.get("value")]]
|
||||
|
||||
items = frappe.get_list("Item", filters=item_filters, pluck="name")
|
||||
if not items:
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"UOM Conversion Detail",
|
||||
filters={"parent": ("in", items), "uom": ("like", f"{txt}%")},
|
||||
filters={"parent": ("in", items), "parenttype": "Item", "uom": ("like", f"{txt}%")},
|
||||
fields=["uom"],
|
||||
as_list=1,
|
||||
distinct=True,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"autoname": "ACC-PDA-.#####",
|
||||
"creation": "2019-11-04 18:01:23.454775",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/process-deferred-accounting",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -74,6 +75,7 @@
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"icon": "list-clock",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"reqd": 0
|
||||
}
|
||||
],
|
||||
"icon": "scale",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "file-clock",
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "lock",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
|
||||
@@ -133,14 +133,17 @@ def initialize_parallel_threads(docname: str):
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def start_pcv_processing(docname: str):
|
||||
# checked before the status is read, not inside the branch: otherwise an unentitled caller
|
||||
# learns the document's status from whether this returns or throws
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
initialize_parallel_threads(docname)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def pause_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
@@ -157,7 +160,7 @@ def pause_pcv_processing(docname: str):
|
||||
qb.update(ppcvd).set(ppcvd.status, "Paused").where(ppcvd.name.isin(queued_dates)).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def cancel_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
|
||||
|
||||
@@ -173,7 +176,7 @@ def cancel_pcv_processing(docname: str):
|
||||
qb.update(ppcvd).set(ppcvd.status, "Cancelled").where(ppcvd.name.isin(queued_dates)).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def resume_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
@@ -258,8 +261,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
return gl_entry
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def schedule_next_date(docname: str):
|
||||
# marks a row Running and enqueues a long job, so it needs the same write check as the sibling controls
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"autoname": "Prompt",
|
||||
"creation": "2020-05-22 16:46:18.712954",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/process-statement-of-accounts",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
@@ -424,6 +425,7 @@
|
||||
"label": "Show Opening Entries"
|
||||
}
|
||||
],
|
||||
"icon": "mail",
|
||||
"links": [],
|
||||
"modified": "2026-06-01 15:37:07.660442",
|
||||
"modified_by": "Administrator",
|
||||
|
||||
@@ -320,6 +320,13 @@ def get_html(doc, filters, entry, col, res, ageing):
|
||||
from frappe.www.printview import get_letter_head
|
||||
|
||||
letter_head = get_letter_head(doc, 0)
|
||||
# render letter head content as a template so its Jinja resolves against the doc
|
||||
if letter_head.get("content"):
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
|
||||
letter_head["content"] = frappe.render_template(letter_head["content"], {"doc": doc})
|
||||
if letter_head.get("footer"):
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
|
||||
letter_head["footer"] = frappe.render_template(letter_head["footer"], {"doc": doc})
|
||||
html = frappe.render_template(
|
||||
template_path,
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user