Compare commits

...

72 Commits

Author SHA1 Message Date
Shllokkk
06d60f66b1 Merge pull request #59388 from frappe/mergify/bp/version-15-hotfix/pr-59386
fix: skip activities fetch on unsaved CRM doc (backport #59386)
2026-09-24 18:18:14 +05:30
Shllokkk
a344958a73 fix: skip activities fetch on unsaved CRM doc
(cherry picked from commit 87052020fb)
2026-09-24 12:41:38 +00:00
Sudharsanan Ashok
c39fdadd1f Merge pull request #59384 from frappe/mergify/bp/version-15-hotfix/pr-59381
fix(stock): restrict delivery to reserved serial nos when ensure delivery by serial no is set (backport #59381)
2026-09-24 17:07:02 +05:30
Sudharsanan Ashok
9703007188 fix(stock): restrict delivery to reserved serial nos when ensure delivery by serial no is set (#59381)
delivery note and sales invoice with update stock now throw on submit if a sales order item with ensure delivery based on produced serial no has no serial reservation, or if a delivered serial no is not one of the serial nos reserved for it. the check runs after the stock ledger is posted, so serial nos auto picked on submit are validated too. the checkbox is hidden and cleared on the sales order when stock reservation is disabled in stock settings, and the check is skipped in that case.

(cherry picked from commit 5306aaf53d)
2026-09-24 16:19:30 +05:30
Sudharsanan Ashok
cde7601804 Merge pull request #59365 from frappe/mergify/bp/version-15-hotfix/pr-59168
fix(regional): report uae vat 201 sales vat in company currency  (backport #59168)
2026-09-24 10:58:23 +05:30
Sudharsanan11
6544048b51 fix: resolve conflicts 2026-09-24 10:34:58 +05:30
Sudharsanan Ashok
134f20d6a1 fix(regional): report uae vat 201 sales vat in company currency (#59168)
(cherry picked from commit 6e0ec5af31)

# Conflicts:
#	erpnext/regional/report/uae_vat_201/uae_vat_201.py
2026-09-24 04:39:15 +00:00
mergify[bot]
3a7d756714 fix: round running allocation balance in payment reconciliation (backport #58393) (#59356)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-09-23 22:25:40 +00:00
Diptanil Saha
aea326869b fix(permissions)!: add permission checks to whitelisted methods and the DocPerm rows they need (#59283) 2026-09-23 23:01:55 +05:30
Khushi Rawat
6c55b16f8f Merge pull request #59221 from frappe/mergify/bp/version-15-hotfix/pr-59128
fix: validate coupon pricing rules and validity dates (backport #59128)
2026-09-23 19:26:20 +05:30
Sudharsanan Ashok
e7e76c164f Merge pull request #59338 from frappe/mergify/bp/version-15-hotfix/pr-59336
fix(selling): use preferred addresses in customer quotations (backport #59336)
2026-09-23 19:16:41 +05:30
pandiyan
2f663d6da7 test: cover coupon pricing rule and date validation 2026-09-23 18:55:44 +05:30
pandiyan
3fbf440c77 fix: validate coupon pricing rules and validity dates 2026-09-23 18:54:44 +05:30
pandiyan
e291ce2616 fix(selling): use preferred addresses in customer quotations 2026-09-23 18:48:10 +05:30
mergify[bot]
879c5c72a1 fix(pos): quote the reversed row's rate on a consolidated credit note (backport #59320) (#59322)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-23 15:42:43 +05:30
Mihir Kandoi
7fe230ac6e fix: continuous raw material consumption with bom validation (backport #51914) (#59314) 2026-09-23 08:12:34 +00:00
Mihir Kandoi
6d3304d3ec fix(manufacturing): finish a work order after partial material consumption (#59311) 2026-09-23 07:49:59 +00:00
Sudharsanan Ashok
42aaeb924e Merge pull request #59296 from frappe/mergify/bp/version-15-hotfix/pr-59231
fix(accounts): keep price list within user permissions (backport #59231)
2026-09-23 12:03:34 +05:30
Sudharsanan11
672518756a test: add stock for internal transfer price list validation 2026-09-23 11:37:22 +05:30
Sudharsanan11
32c8a9507d fix: resolve conflicts 2026-09-23 11:37:22 +05:30
Sudharsanan Ashok
004633a97e fix(accounts): keep price list within user permissions (#59231)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit e814d13126)

# Conflicts:
#	erpnext/accounts/party.py
#	erpnext/accounts/test_party.py
2026-09-23 11:37:22 +05:30
Sudharsanan Ashok
8985c36397 Merge pull request #59294 from frappe/mergify/bp/version-15-hotfix/pr-59286
fix(stock): keep mapped cost center in stock entry items (backport #59286)
2026-09-22 23:45:53 +05:30
Khushi Rawat
eb75b22c9a Merge pull request #59288 from frappe/mergify/bp/version-15-hotfix/pr-59262
fix(journal-entry): carry reference fields into reverse journal entry (backport #59262)
2026-09-22 23:17:01 +05:30
Mihir Kandoi
83ab572208 fix(stock): deduct rejected qty from the in-transit warehouse (#59300) 2026-09-22 14:33:56 +00:00
Afsal Syed
a41858a47d fix(stock): allow None for for_validate in get_item_details (#59290) 2026-09-22 19:37:15 +05:30
Sudharsanan Ashok
091fcfee7e fix(stock): keep mapped cost center in stock entry items (#59286)
(cherry picked from commit 7f608f31eb)
2026-09-22 12:35:38 +00:00
khushi8112
5237c8189a fix: apply journal entry reversal backport to version 15 controller 2026-09-22 17:33:39 +05:30
Mihir Kandoi
3b9c74024a fix(stock): use net purchase values in landed cost vouchers (backport #59274) (#59279)
Co-authored-by: pandiyan <pandiyanpalani37@gmail.com>
2026-09-22 12:02:46 +00:00
ervishnucs
f4ce7b2677 fix(journal-entry): carry reference fields into reverse journal entry
(cherry picked from commit 9e01992217)

# Conflicts:
#	erpnext/accounts/doctype/journal_entry/mapper.py
2026-09-22 11:47:21 +00:00
rohitwaghchaure
10172869fe fix: do not zero out backdated stock at a stock reco adjustment entry (#59271)
* fix: do not zero out backdated stock at a stock reco adjustment entry

* fix: keep a stock reco adjustment entry value-only on cancel and refresh

* fix: read stock reco adjustment rows once and value them from the ledger
2026-09-22 16:29:45 +05:30
mergify[bot]
7d5293e83e fix(stock): compare transit quantities in stock UOM (backport #59202) (#59243)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 08:50:17 +00:00
Sudharsanan Ashok
9fe04988c2 Merge pull request #59254 from frappe/mergify/bp/version-15-hotfix/pr-59192
fix(stock): apply transit warehouse filter to target warehouse in items table (backport #59192)
2026-09-22 13:38:45 +05:30
Sudharsanan Ashok
f3078ed56b Merge pull request #59193 from frappe/mergify/bp/version-15-hotfix/pr-59191
fix(stock): validate warehouse account belongs to selected company (backport #59191)
2026-09-22 13:38:32 +05:30
Afsal Syed
e13908400b fix(stock): apply transit warehouse filter to target warehouse in items table (#59192)
(cherry picked from commit 893fc75df4)
2026-09-22 12:32:48 +05:30
Afsal Syed
5b2a0e73c3 fix(stock): validate warehouse account belongs to selected company (#59191)
(cherry picked from commit db6e089109)
2026-09-22 11:40:44 +05:30
Diptanil Saha
89de899c95 fix(permissions): grant select on link targets to roles with write access (#59232) 2026-09-21 17:28:56 +05:30
Shllokkk
4f122ab2b3 Merge pull request #59229 from frappe/mergify/bp/version-15-hotfix/pr-59204
perf: filter by company in get_returned_invoice_items (backport #59204)
2026-09-21 15:14:06 +05:30
Shllokkk
4fbc2791b8 perf: filter by company in get_returned_invoice_items
(cherry picked from commit 423aa09f59)
2026-09-21 09:02:25 +00:00
rohitwaghchaure
7a276344a3 fix: write off stranded stock value only when the warehouse is empty (#59219) 2026-09-21 13:30:14 +05:30
Shllokkk
e4ea6e0a04 Merge pull request #59205 from frappe/mergify/bp/version-15-hotfix/pr-59203
refactor: migrate raw SQL queries in gross profit report to query builder (backport #59203)
2026-09-21 02:44:56 +05:30
Shllokkk
9e5baaa571 refactor: migrate raw SQL queries in gross profit report to query builder
(cherry picked from commit fd369a5a0f)

# Conflicts:
#	erpnext/accounts/report/gross_profit/gross_profit.py
2026-09-21 02:28:30 +05:30
Sudharsanan Ashok
54ac519bec Merge pull request #59164 from frappe/mergify/bp/version-15-hotfix/pr-59160
fix(stock): forward company to outward batch lookup (backport #59160)
2026-09-18 18:27:01 +05:30
Khushi Rawat
a7151374bb Merge pull request #59183 from frappe/mergify/bp/version-15-hotfix/pr-59181
fix(accounts): clarify revaluation journal filters (backport #59181)
2026-09-18 17:57:44 +05:30
pandiyan
a602153753 fix(accounts): clarify revaluation journal filters
(cherry picked from commit dc4b390a9b)
2026-09-18 12:23:17 +00:00
Sudharsanan Ashok
f73cdb37bf Merge pull request #59158 from frappe/mergify/bp/version-15-hotfix/pr-59155
fix(stock): continue putaway allocation after undersized whole-uom rules (backport #59155)
2026-09-18 15:16:10 +05:30
Pandiyan P
f5f7d04fd5 fix(stock): forward company to outward batch lookup (#59160)
(cherry picked from commit 622b7e0caa)
2026-09-18 09:07:07 +00:00
Khushi Rawat
fc1d5975e9 Merge pull request #59162 from frappe/mergify/bp/version-15-hotfix/pr-59142
fix: validate shipping rule account company (backport #59142)
2026-09-18 14:36:57 +05:30
Khushi Rawat
0983fb1e9c Merge pull request #59156 from frappe/mergify/bp/version-15-hotfix/pr-59081
fix(accounts): validate bank reconciliation date ranges (backport #59081)
2026-09-18 14:23:38 +05:30
pandiyan
e6930317ae fix(stock): continue putaway allocation after undersized whole-uom rules (#59155)
(cherry picked from commit 6cc500e28d)
2026-09-18 14:16:00 +05:30
pandiyan
7186e45de9 test: cover shipping rule account company validation
(cherry picked from commit 022ee73d78)

# Conflicts:
#	erpnext/accounts/doctype/shipping_rule/test_shipping_rule.py
2026-09-18 14:11:20 +05:30
pandiyan
e54bf6fe43 fix: validate shipping rule account company
(cherry picked from commit 340feb94a1)
2026-09-18 08:37:12 +00:00
pandiyan
355033d40f test: cover bank reconciliation date validation
(cherry picked from commit 62d0e2c82e)

# Conflicts:
#	erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py
2026-09-18 13:54:26 +05:30
pandiyan
dcff5c3149 fix: validate bank reconciliation date ranges
(cherry picked from commit 970f32a342)

# Conflicts:
#	erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
2026-09-18 13:53:14 +05:30
Sudharsanan Ashok
22a860d380 Merge pull request #59089 from frappe/mergify/bp/version-15-hotfix/pr-58923
fix(stock): filter item group defaults by company and non-group records (backport #58923)
2026-09-18 11:12:11 +05:30
rohitwaghchaure
dc08520197 perf: reduce memory consumption during reposting (#59117)
* perf: reduce memory consumption during reposting

The reposting queue introduced in #52152 keeps the complete future stock
ledger of every transitively dependent item-warehouse in a single deque.
Each `select *` row is ~3.5KB, so a repost spanning a million entries
needs several GB and gets OOM killed on smaller workers.

- Queue only the identity and sort keys of an entry, and fetch the full
  row in batches of 500 just before it is processed
- Move `for update` off the bulk prefetch onto the batch, so millions of
  rows are no longer locked for the whole duration of the repost
- Drop the process local document cache and message log at every
  checkpoint, neither is evicted within a long running job

* fix: keep recent repost messages and log skipped entries

Trimming `frappe.local.message_log` to empty at every checkpoint discarded
warnings that the Repost Item Valuation failure handler reads back when
building the error log. Keep the most recent messages instead, which bounds
the growth just as well.

Also log when a queued entry is no longer active by the time its batch is
loaded, rather than skipping it silently.

* fix: skip reposts already covered by a Manufacture/Repack dependant repost

While reposting a raw material, the finished goods produced from it are
reposted as dependants, from the posting datetime of the manufacture entry
through to the end of their ledger. A repost queued separately for the same
finished good and warehouse at a later datetime therefore has nothing left to
do, but it was still picked up and walked the same entries again.

Track the item-warehouse combinations pulled in as dependants of a Manufacture
or Repack entry, and mark the redundant queued reposts as Skipped once the
dependants have been reposted. This runs per item being reposted, so the
finished good's queued repost is released without waiting for the whole raw
material repost to finish.

Only 'Item and Warehouse' reposts are skipped. A 'Transaction' repost spans
several item-warehouse combinations, so covering one says nothing about the
rest. Reposts starting before the manufacture entry still have work to do and
are left queued.

* fix: don't lock the whole repost queue prefetch

* test: repost covers every entry once across batches
2026-09-18 09:02:56 +05:30
Sudharsanan Ashok
1ad584fddc Merge pull request #59118 from frappe/mergify/bp/version-15-hotfix/pr-59104
fix(stock): allow zero completed quantity and handle process loss in job cards (backport #59104)
2026-09-17 19:22:55 +05:30
Sudharsanan Ashok
d6d0400f76 Merge pull request #59087 from frappe/mergify/bp/version-15-hotfix/pr-58998
fix(stock): use stored posting_datetime when reposting stock ledger entries (backport #58998)
2026-09-17 18:47:19 +05:30
Sudharsanan11
ed33afd0fa fix: resolve conflicts 2026-09-17 18:28:07 +05:30
Sudharsanan Ashok
c7893b8444 fix(stock): use stored posting_datetime when reposting stock ledger entries (#58998)
* fix(stock): use stored posting_datetime for repost boundary

get_stock_ledger_entries re-derived posting_datetime from posting_date and
posting_time on every call, discarding the stored value its callers pass in.
when a row's stored posting_datetime differs from that pair, the replay window
is built from the wrong instant: the row falls outside the range filter and is
never recomputed, while get_previous_sle still selects it as the opening
balance and reuses its stale qty_after_transaction. every later entry inherits
the error, leaving bin qty adrift from the sum of its ledger.

derive the boundary only when the caller has not supplied one.

* fix(stock): match current voucher sle on stored posting_datetime

get_sle_against_current_voucher selected rows with an equality check against a
posting_datetime re-derived from posting_date and posting_time. a row whose
stored posting_datetime differs from that pair matches nothing, so reposting
the voucher silently processes zero entries and the row can never be corrected
through its own voucher.

read the timestamp from the stored row when the sle is known, and derive it
only as a fallback.

* test(stock): cover repost with diverged posting_datetime

add a repack scenario whose incoming entry stores a posting_datetime one
microsecond before its own posting_time. asserts the voucher lookup still
finds that entry, and that reposting replays it instead of reusing its stale
qty_after_transaction, which otherwise left bin qty at 115 against 615 of
recorded movements.

(cherry picked from commit 6cee9c330c)

# Conflicts:
#	erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
2026-09-17 18:28:07 +05:30
Afsal Syed
1e862b232c fix(stock): allow zero completed quantity and handle process loss in job cards (#59104)
(cherry picked from commit 1d8ce1ee8c)
2026-09-17 18:07:54 +05:30
Sudharsanan Ashok
f6b2708f4e Merge pull request #59095 from frappe/mergify/bp/version-15-hotfix/pr-58899
fix: clear stock value for zero-quantity balances (backport #58899)
2026-09-17 11:10:24 +05:30
pandiyan
65d2a07596 fix: clear stock value for zero-quantity balances (#58899)
* fix: clear stock value for zero-quantity balances

* test(stock): cover zero-quantity residual stock values

(cherry picked from commit b439bdda68)
2026-09-17 10:52:56 +05:30
rohitwaghchaure
0130d287f6 perf: skip legacy batch ledger lookups when no legacy entry exists (#59110)
* perf: skip legacy batch ledger lookups when no legacy entry exists
2026-09-16 11:45:49 +00:00
Shllokkk
30cfb2094f Merge pull request #59107 from frappe/mergify/bp/version-15-hotfix/pr-59078
fix: render letter head jinja in statement of accounts pdf (backport #59078)
2026-09-16 16:44:20 +05:30
rohitwaghchaure
a37725b6b9 fix: negative stock value for moving average item with mixed batchwise valuation (#59102)
* fix: negative stock value for moving average item with mixed batchwise valuation

* test: cover fallback rate and equal timestamp valuation paths
2026-09-16 16:29:06 +05:30
mergify[bot]
db2e9efab4 fix: update modified for last purchase rate (backport #59044) (#59105)
fix: update modified for last purchase rate (#59044)

(cherry picked from commit f6b907ef9d)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-16 10:14:44 +00:00
Shllokkk
b8a53b800a fix: render letter head jinja in statement of accounts pdf
(cherry picked from commit da377683d6)
2026-09-16 10:10:12 +00:00
Vishnu Priya Baskaran
a7aae8b21a fix: prevent max recursion on supplier scorecard save (#58706) 2026-09-16 13:12:03 +05:30
pandiyan
50b6b850e4 fix(stock): filter item group defaults by company and non-group records (#58923)
(cherry picked from commit 00ee694444)
2026-09-16 12:05:13 +05:30
Sudharsanan Ashok
b4dd2ee80f Merge pull request #59015 from frappe/mergify/bp/version-15-hotfix/pr-58994
fix(stock): calculate batch bundle valuation per unit (backport #58994)
2026-09-16 11:03:50 +05:30
mergify[bot]
e2830089fb fix: resolve code lists by URI and version (backport #58770) (#58771)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-16 02:37:15 +02:00
pandiyan
7df94d87de fix(stock): calculate batch bundle valuation per unit (#58994)
(cherry picked from commit 825d24f406)
2026-09-12 21:58:24 +05:30
195 changed files with 6844 additions and 700 deletions

View File

@@ -49,6 +49,10 @@ class ERPNextAddress(Address):
@frappe.whitelist()
def get_shipping_address(company, address=None):
# `select`, not `read`: the roles that fill in these transactions hold no Company `read` row.
# doc= so the named company is evaluated and User Permissions apply.
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
filters = [
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],

View File

@@ -25,6 +25,7 @@ def get(
):
if chart_name:
chart = frappe.get_doc("Dashboard Chart", chart_name)
chart.check_permission("read")
else:
chart = frappe._dict(frappe.parse_json(chart))
timespan = chart.timespan
@@ -53,6 +54,9 @@ def get(
)
)
# 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:

View File

@@ -191,7 +191,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2024-08-19 15:19:11.095045",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",
@@ -244,6 +244,46 @@
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"search_fields": "account_number",

View File

@@ -502,12 +502,19 @@ class Account(NestedSet):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_parent_account(doctype, txt, searchfield, start, page_len, filters):
return frappe.db.sql(
"""select name from tabAccount
where is_group = 1 and docstatus != 2 and company = {}
and {} like {} order by name limit {} offset {}""".format("%s", searchfield, "%s", "%s", "%s"),
(filters["company"], "%%%s%%" % txt, page_len, start),
as_list=1,
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,
)

View File

@@ -120,6 +120,8 @@ def identify_is_group(child):
def get_chart(chart_template, existing_company=None):
chart = {}
if existing_company:
frappe.has_permission("Company", doc=existing_company, throw=True)
return get_account_tree_from_existing_company(existing_company)
elif chart_template == "Standard":

View File

@@ -207,8 +207,11 @@ def delete_accounting_dimension(doc):
frappe.clear_cache(doctype=doctype)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def disable_dimension(doc):
# 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.flags.in_test:
toggle_disabling(doc=doc)
else:

View File

@@ -101,7 +101,7 @@
}
],
"links": [],
"modified": "2020-07-17 14:00:13.105433",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank",
@@ -118,6 +118,14 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "Accounts User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -254,7 +254,7 @@
"link_fieldname": "default_bank_account"
}
],
"modified": "2026-01-20 00:46:16.633364",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",
@@ -284,6 +284,22 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

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

View File

@@ -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");
},
@@ -101,7 +102,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(() => {

View File

@@ -3,13 +3,14 @@
import json
from datetime import date
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import 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
@@ -47,8 +48,12 @@ class BankReconciliationTool(Document):
@frappe.whitelist()
def get_bank_transactions(bank_account, from_date=None, to_date=None):
def get_bank_transactions(
bank_account: str, from_date: str | date | None = None, to_date: str | date | None = None
):
# 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])
@@ -372,13 +377,14 @@ def create_payment_entry_bts(
@frappe.whitelist()
def auto_reconcile_vouchers(
bank_account,
from_date=None,
to_date=None,
filter_by_reference_date=None,
from_reference_date=None,
to_reference_date=None,
bank_account: str,
from_date: str | date | None = None,
to_date: str | date | None = None,
filter_by_reference_date: bool | 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:
@@ -488,15 +494,16 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
@frappe.whitelist()
def get_linked_payments(
bank_transaction_name,
document_types=None,
from_date=None,
to_date=None,
filter_by_reference_date=None,
from_reference_date=None,
to_reference_date=None,
bank_transaction_name: str,
document_types: str | list[str] | None = None,
from_date: str | date | None = None,
to_date: str | date | None = None,
filter_by_reference_date: bool | 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
@@ -516,6 +523,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 = []

View File

@@ -10,6 +10,7 @@ from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
get_bank_transactions,
get_linked_payments,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -98,3 +99,42 @@ class TestBankReconciliationTool(AccountsTestMixin, FrappeTestCase):
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
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 = (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": today(),
"deposit": 100,
"bank_account": self.bank_account,
"currency": "INR",
}
)
.insert()
.submit()
)
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,
)

View File

@@ -296,6 +296,10 @@ def get_import_logs(docname: str):
@frappe.whitelist()
def upload_bank_statement(**args):
# `create`: the sole caller is the Bank Reconciliation Tool's upload button, which routes
# straight into a new Bank Statement Import form.
frappe.has_permission("Bank Statement Import", "create", throw=True)
args = frappe._dict(args)
bsi = frappe.new_doc("Bank Statement Import")

View File

@@ -11,6 +11,9 @@ 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
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 +39,11 @@ def upload_bank_statement():
@frappe.whitelist()
def create_bank_entries(columns, data, bank_account):
# insert()/submit() enforce this per document, but only after the row loop has written an
# Error Log for every rejected row -- so check once up front.
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

View File

@@ -8,6 +8,7 @@ from functools import reduce
import frappe
from frappe import _
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.desk.form.linked_with import get_linked_fields
from frappe.model.document import Document
from frappe.utils import cint, cstr
@@ -58,6 +59,8 @@ def validate_columns(data):
@frappe.whitelist()
def validate_company(company):
frappe.has_permission("Chart of Accounts Importer", throw=True)
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
)
@@ -110,7 +113,11 @@ def import_coa(file_name, company):
def get_file(file_name):
file_doc = frappe.get_doc("File", {"file_url": file_name})
# look the file up through find_file_by_url, which returns it only when the caller may download it
file_doc = find_file_by_url(file_name)
if not file_doc:
raise frappe.PermissionError
parts = file_doc.get_extension()
extension = parts[1]
extension = extension.lstrip(".")
@@ -176,6 +183,7 @@ def generate_data_from_excel(file_doc, extension, as_dict=False):
@frappe.whitelist()
def get_coa(doctype, parent, is_root=False, file_name=None, for_validate=0):
"""called by tree view (to fetch node's children)"""
frappe.has_permission("Chart of Accounts Importer", throw=True)
file_doc, extension = get_file(file_name)
parent = None if parent == _("All Accounts") else parent
@@ -324,6 +332,8 @@ def build_response_as_excel(writer):
@frappe.whitelist()
def download_template(file_type, template_type, company):
frappe.has_permission("Chart of Accounts Importer", throw=True)
writer = get_template(template_type, company)
if file_type == "CSV":

View File

@@ -125,7 +125,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2024-04-24 10:55:54.083042",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cost Center",
@@ -172,6 +172,54 @@
"role": "Employee",
"select": 1,
"share": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Projects Manager",
"select": 1
},
{
"role": "Projects User",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"search_fields": "parent_cost_center, is_group",

View File

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

View File

@@ -115,7 +115,7 @@
"read_only": 1
}
],
"modified": "2019-10-19 14:48:14.602481",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Coupon Code",
@@ -168,6 +168,14 @@
"role": "Website Manager",
"share": 1,
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
}
],
"sort_field": "modified",

View File

@@ -41,7 +41,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.get("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)))

View File

@@ -115,8 +115,46 @@ class TestCouponCode(unittest.TestCase):
test_create_test_data()
def tearDown(self):
frappe.db.rollback()
frappe.set_user("Administrator")
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)

View File

@@ -56,7 +56,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2018-05-09 14:55:01.394387",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Finance Book",
@@ -119,6 +119,18 @@
"share": 1,
"submit": 0,
"write": 0
},
{
"role": "HR Manager",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -82,7 +82,7 @@
"icon": "fa fa-calendar",
"idx": 1,
"links": [],
"modified": "2024-05-27 17:29:55.560840",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Fiscal Year",
@@ -131,6 +131,10 @@
{
"read": 1,
"role": "Auditor"
},
{
"role": "Sales Master Manager",
"select": 1
}
],
"show_name_in_global_search": 1,

View File

@@ -57,7 +57,7 @@
}
],
"links": [],
"modified": "2023-07-09 18:11:23.105589",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Item Tax Template",
@@ -95,6 +95,54 @@
"report": 1,
"role": "Accounts User",
"share": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Item Manager",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
},
{
"role": "Stock User",
"select": 1
}
],
"show_name_in_global_search": 1,

View File

@@ -1479,6 +1479,10 @@ class JournalEntryTaxWithholding:
def get_default_bank_cash_account(company, account_type=None, mode_of_payment=None, account=None):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
# `select`, not `read`: also runs server-side from get_payment_entry, and Auditor/HR User/
# Desk User hold only the select row on Company. doc= for User Permissions.
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")
@@ -1507,6 +1511,10 @@ def get_default_bank_cash_account(company, account_type=None, mode_of_payment=No
account = account_list[0].name
if account:
# `account` is caller-supplied; get_balance_on() checks it only on the branch that reads a
# balance, and `fetch_balance` is caller-supplied too.
frappe.has_permission("Account", doc=account, throw=True)
account_details = frappe.get_cached_value(
"Account", account, ["account_currency", "account_type"], as_dict=1
)
@@ -1692,29 +1700,39 @@ def get_against_jv(doctype, txt, searchfield, start, page_len, filters):
if not frappe.db.has_column("Journal Entry", searchfield):
return []
return frappe.db.sql(
f"""
SELECT jv.name, jv.posting_date, jv.user_remark
FROM `tabJournal Entry` jv, `tabJournal Entry Account` jv_detail
WHERE jv_detail.parent = jv.name
AND jv_detail.account = %(account)s
AND IFNULL(jv_detail.party, '') = %(party)s
AND (
jv_detail.reference_type IS NULL
OR jv_detail.reference_type = ''
)
AND jv.docstatus = 1
AND jv.`{searchfield}` LIKE %(txt)s
ORDER BY jv.name DESC
LIMIT %(limit)s offset %(offset)s
""",
dict(
account=filters.get("account"),
party=cstr(filters.get("party")),
txt=f"%{txt}%",
offset=start,
limit=page_len,
),
account = filters.get("account")
party = filters.get("party")
# 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"]
)
return frappe.get_list(
"Journal Entry",
filters=je_filters,
fields=["name", "posting_date", "user_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",
)
@@ -1919,6 +1937,10 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
def post_process(source, target):
target.reversal_of = source.name
target.naming_series = source.naming_series
if source.voucher_type == "Bank Entry":
target.cheque_no = source.cheque_no
target.cheque_date = source.cheque_date
doclist = get_mapped_doc(
"Journal Entry",

View File

@@ -152,7 +152,7 @@
"options": "Project"
}
],
"modified": "2019-05-26 09:11:46.120251",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Loyalty Program",
@@ -169,6 +169,14 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -86,6 +86,8 @@ def get_loyalty_program_details_with_points(
include_expired_entry=False,
current_transaction_amount=0,
):
frappe.has_permission("Customer", doc=customer, throw=True)
lp_details = get_loyalty_program_details(customer, loyalty_program, company=company, silent=silent)
loyalty_program = frappe.get_doc("Loyalty Program", loyalty_program)
lp_details.update(
@@ -116,6 +118,10 @@ def get_loyalty_program_details(
silent=False,
include_expired_entry=False,
):
# authorise the customer, not the programme: a Loyalty Program check is read-only to System
# Manager and would deny every role that fills in the two calling forms.
frappe.has_permission("Customer", doc=customer, throw=True)
lp_details = frappe._dict()
if not loyalty_program:
@@ -137,6 +143,13 @@ def get_loyalty_program_details(
@frappe.whitelist()
def get_redeemption_factor(loyalty_program=None, customer=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")

View File

@@ -48,7 +48,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-09-18 17:57:23.835236",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Mode of Payment",
@@ -68,6 +68,30 @@
"read": 1,
"report": 1,
"role": "Accounts User"
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -46,7 +46,7 @@
"icon": "fa fa-bar-chart",
"idx": 1,
"links": [],
"modified": "2024-01-30 13:57:55.802744",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Monthly Distribution",
@@ -69,6 +69,10 @@
"read": 1,
"report": 1,
"role": "Accounts Manager"
},
{
"role": "Sales Master Manager",
"select": 1
}
],
"sort_field": "modified",

View File

@@ -57,26 +57,56 @@ class PaymentOrder(Document):
frappe.db.set_value(self.payment_order_type, d.get(ref_doc_field), ref_field, status)
def _readable_payment_order(filters):
"""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, txt, searchfield, start, page_len, filters):
return frappe.db.sql(
""" select mode_of_payment from `tabPayment Order Reference`
where parent = %(parent)s and mode_of_payment like %(txt)s
limit %(page_len)s offset %(start)s""",
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
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}%"]},
fields=["mode_of_payment"],
limit_start=start,
limit_page_length=page_len,
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
as_list=True,
)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
return frappe.db.sql(
""" select supplier from `tabPayment Order Reference`
where parent = %(parent)s and supplier like %(txt)s and
(payment_reference is null or payment_reference='')
limit %(page_len)s offset %(start)s""",
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
if not _readable_payment_order(filters):
return []
return frappe.get_all(
"Payment Order Reference",
filters={
"parent": filters.get("parent"),
"supplier": ["like", f"%{txt}%"],
"payment_reference": ["is", "not set"],
},
fields=["supplier"],
limit_start=start,
limit_page_length=page_len,
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
as_list=True,
)

View File

@@ -455,6 +455,13 @@ class PaymentReconciliation(Document):
default_exchange_gain_loss_account = frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
)
account_currency = frappe.get_cached_value(
"Account", self.receivable_payable_account, "account_currency"
)
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount"),
currency=account_currency,
)
entries = []
for pay in args.get("payments"):
@@ -462,11 +469,17 @@ class PaymentReconciliation(Document):
for inv in args.get("invoices"):
if pay.get("amount") >= inv.get("outstanding_amount"):
res = self.get_allocated_entry(pay, inv, inv["outstanding_amount"])
pay["amount"] = flt(pay.get("amount")) - flt(inv.get("outstanding_amount"))
pay["amount"] = flt(
flt(pay.get("amount")) - flt(inv.get("outstanding_amount")),
allocated_amount_precision,
)
inv["outstanding_amount"] = 0
else:
res = self.get_allocated_entry(pay, inv, pay["amount"])
inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount"))
inv["outstanding_amount"] = flt(
flt(inv.get("outstanding_amount")) - flt(pay.get("amount")),
allocated_amount_precision,
)
pay["amount"] = 0
inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number"))

View File

@@ -1484,6 +1484,75 @@ class TestPaymentReconciliation(FrappeTestCase):
# Should not raise frappe.exceptions.ValidationError: Payment Entry has been modified after you pulled it. Please pull it again.
pr.reconcile()
@change_settings("System Settings", {"currency_precision": 2})
def test_allocate_entries_rounds_running_balance_to_currency_precision(self):
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
pr.party_type = "Customer"
pr.party = self.customer
pr.receivable_payable_account = self.debit_to
pr.set("invoices", [{"invoice_number": "INV-1"}])
pr.set("payments", [{"reference_name": "PAY-1"}])
invoices = [
{
"invoice_type": "Sales Invoice",
"invoice_number": "INV-1",
"outstanding_amount": 17592.415,
"currency": "INR",
},
]
payments = [
{
"reference_type": "Payment Entry",
"reference_name": "PAY-1",
"amount": 18230,
"currency": "INR",
}
]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(payments[0]["amount"], flt(637.585, 2))
@change_settings("System Settings", {"currency_precision": "", "use_number_format_from_currency": 1})
def test_allocate_entries_rounds_running_balance_to_account_currency_precision(self):
account_currency = frappe.get_cached_value("Account", self.debit_to, "account_currency")
original_number_format = frappe.db.get_value("Currency", account_currency, "number_format")
frappe.db.set_value("Currency", account_currency, "number_format", "#,###.###")
self.addCleanup(
frappe.db.set_value, "Currency", account_currency, "number_format", original_number_format
)
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
pr.party_type = "Customer"
pr.party = self.customer
pr.receivable_payable_account = self.debit_to
pr.set("invoices", [{"invoice_number": "INV-1"}])
pr.set("payments", [{"reference_name": "PAY-1"}])
invoices = [
{
"invoice_type": "Sales Invoice",
"invoice_number": "INV-1",
"outstanding_amount": 17592.415,
"currency": account_currency,
},
]
payments = [
{
"reference_type": "Payment Entry",
"reference_name": "PAY-1",
"amount": 18230,
"currency": account_currency,
}
]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(payments[0]["amount"], flt(637.585, 3))
def test_reverse_payment_against_payment_for_supplier(self):
"""
Reconcile a payment against a reverse payment, for a supplier.

View File

@@ -116,7 +116,7 @@
}
],
"links": [],
"modified": "2021-02-15 20:30:56.256403",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Term",
@@ -157,6 +157,30 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -147,12 +147,22 @@ class POSClosingEntry(StatusUpdater):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
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]
@frappe.whitelist()
def get_pos_invoices(start, end, pos_profile, user):
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
frappe.has_permission("POS Invoice", throw=True)
data = frappe.db.sql(
"""
select

View File

@@ -1572,7 +1572,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-08-12 12:00:00.000000",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
@@ -1615,6 +1615,14 @@
"permlevel": 1,
"read": 1,
"role": "All"
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -801,6 +801,21 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist()
def get_stock_availability(item_code, warehouse):
# POS Profile is the only boundary that fits: Item/Bin `read` exclude Accounts Manager, Item
# `select` is granted to every desk user, and POS Invoice `read` is granted to `All`.
# select-or-read: the shipped rows give Sales Manager only `select`.
ptype = "select" if frappe.only_has_select_perm("POS Profile") else "read"
frappe.has_permission("POS Profile", ptype, throw=True)
# the caller picks the warehouse when allow_warehouse_change is set; 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)

View File

@@ -217,6 +217,8 @@ class POSInvoiceMergeLog(Document):
loyalty_amount_sum, loyalty_points_sum, idx = 0, 0, 1
reversed_rows = get_reversed_rows([doc.return_against for doc in data if doc.is_return])
for doc in data:
map_doc(doc, invoice, table_map={"doctype": invoice.doctype})
@@ -239,9 +241,13 @@ class POSInvoiceMergeLog(Document):
si_item.pos_invoice = doc.name
si_item.pos_invoice_item = item.name
if doc.is_return:
si_item.sales_invoice_item = get_sales_invoice_item(
doc.return_against, item.pos_invoice_item
)
reversed_row = reversed_rows.get(item.pos_invoice_item) or frappe._dict()
si_item.sales_invoice_item = reversed_row.get("name")
# quote the rate of the row being reversed: rounding an invoice-level discount
# can leave a return's net rate a minor unit above the sale's, and
# validate_returned_items refuses a return priced above its original
if si_item.sales_invoice_item:
si_item.rate = reversed_row.rate
if item.serial_and_batch_bundle:
si_item.serial_and_batch_bundle = item.serial_and_batch_bundle
items.append(si_item)
@@ -439,6 +445,29 @@ def update_item_wise_tax_detail(consolidate_tax_row, tax_row):
consolidate_tax_row.item_wise_tax_detail = json.dumps(consolidated_tax_detail, separators=(",", ":"))
def get_reversed_rows(return_against):
"""Rows of the consolidated sales these returns reverse, keyed by the POS invoice row."""
if not return_against:
return {}
sales_invoice = DocType("Sales Invoice")
sales_invoice_item = DocType("Sales Invoice Item")
rows = (
frappe.qb.from_(sales_invoice)
.from_(sales_invoice_item)
.select(sales_invoice_item.name, sales_invoice_item.rate, sales_invoice_item.pos_invoice_item)
.where(
(sales_invoice.name == sales_invoice_item.parent)
& (sales_invoice.is_return == 0)
& (sales_invoice_item.pos_invoice.isin(return_against))
& (sales_invoice.docstatus == 1)
)
).run(as_dict=True)
return {row.pos_invoice_item: row for row in rows}
def get_all_unconsolidated_invoices():
filters = {
"consolidated_invoice": ["in", ["", None]],
@@ -683,27 +712,3 @@ def get_error_message(message) -> str:
return message["message"]
except Exception:
return str(message)
def get_sales_invoice_item(return_against_pos_invoice, pos_invoice_item):
try:
SalesInvoice = DocType("Sales Invoice")
SalesInvoiceItem = DocType("Sales Invoice Item")
query = (
frappe.qb.from_(SalesInvoice)
.from_(SalesInvoiceItem)
.select(SalesInvoiceItem.name)
.where(
(SalesInvoice.name == SalesInvoiceItem.parent)
& (SalesInvoice.is_return == 0)
& (SalesInvoiceItem.pos_invoice == return_against_pos_invoice)
& (SalesInvoiceItem.pos_invoice_item == pos_invoice_item)
& (SalesInvoice.docstatus == 1)
)
)
result = query.run(as_dict=True)
return result[0].name if result else None
except Exception:
return None

View File

@@ -3,9 +3,11 @@
import json
import unittest
from contextlib import contextmanager
import frappe
from frappe.tests.utils import change_settings
from frappe.utils import flt
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
@@ -19,6 +21,67 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@contextmanager
def rounding_method(method):
"""System Settings is also cached on frappe.local, so that copy has to go as well."""
previous = frappe.db.get_single_value("System Settings", "rounding_method")
try:
frappe.db.set_single_value("System Settings", "rounding_method", method)
frappe.local.system_settings = None
yield
finally:
frappe.db.set_single_value("System Settings", "rounding_method", previous)
frappe.local.system_settings = None
def sell_over_the_counter(lines, discount_percentage=0):
item_code, qty, rate = lines[0]
sale = create_pos_invoice(item_code=item_code, qty=qty, rate=rate, do_not_save=True)
for item_code, qty, rate in lines[1:]:
sale.append(
"items",
{
"item_code": item_code,
"qty": qty,
"rate": rate,
"price_list_rate": rate,
"warehouse": "_Test Warehouse - _TC",
"income_account": "Sales - _TC",
"cost_center": "_Test Cost Center - _TC",
},
)
if discount_percentage:
sale.apply_discount_on = "Net Total"
sale.additional_discount_percentage = discount_percentage
sale.run_method("calculate_taxes_and_totals")
payable = sale.rounded_total or sale.grand_total
sale.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": payable})
sale.paid_amount = sale.base_paid_amount = payable
sale.insert()
sale.submit()
return sale
def refund_over_the_counter(sale, qty=None):
"""Hand back every line of `sale`, `qty` of each when fewer units come back."""
note = make_sales_return(sale.name)
if qty is not None:
for item in note.items:
item.qty = qty
note.run_method("calculate_taxes_and_totals")
refundable = note.rounded_total or note.grand_total
note.payments[0].amount = refundable
for spare in note.payments[1:]:
spare.amount = 0
note.paid_amount = note.base_paid_amount = refundable
note.insert()
note.submit()
return note
class TestPOSInvoiceMergeLog(unittest.TestCase):
def test_consolidated_invoice_creation(self):
frappe.db.sql("delete from `tabPOS Invoice`")
@@ -510,3 +573,92 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_consolidating_returns_priced_off_a_rounded_invoice_discount(self):
"""A return works out its own share of an invoice-level discount, so rounding can leave
it a minor unit above the sale's, and validate_returned_items then refuses it.
Every shape that reaches a consolidated credit note goes through one consolidation:
a split landing on a half minor unit, the same item on two rows so the rows can only
be paired through sales_invoice_item, fewer units coming back than went out, and — as
a control — a sale with no invoice-level discount to split at all.
"""
frappe.db.sql("delete from `tabPOS Invoice`")
try:
for item_code in ("_Test Item", "_Test Item 2"):
make_stock_entry(to_warehouse="_Test Warehouse - _TC", item_code=item_code, rate=100, qty=40)
init_user_and_profile()
with rounding_method("Banker's Rounding (legacy)"):
tied = sell_over_the_counter(
[("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)], discount_percentage=25
)
repeated = sell_over_the_counter(
[("_Test Item", 1, 42.86), ("_Test Item", 1, 57.14)], discount_percentage=25
)
oversold = sell_over_the_counter(
[("_Test Item", 3, 42.86), ("_Test Item 2", 3, 57.14)], discount_percentage=25
)
undiscounted = sell_over_the_counter([("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)])
# the sale and the return really do round the split apart
self.assertEqual(
{item.item_code: item.net_rate for item in tied.items},
{"_Test Item": 32.15, "_Test Item 2": 42.85},
)
returns = [
refund_over_the_counter(tied),
refund_over_the_counter(repeated),
refund_over_the_counter(oversold, qty=-1),
refund_over_the_counter(undiscounted),
]
self.assertEqual(
{item.item_code: item.net_rate for item in returns[0].items},
{"_Test Item": 32.14, "_Test Item 2": 42.86},
)
consolidate_pos_invoices()
for pos_invoice in [tied, repeated, oversold, undiscounted, *returns]:
pos_invoice.load_from_db()
self.assertTrue(
frappe.db.exists("Sales Invoice", pos_invoice.consolidated_invoice),
f"{pos_invoice.name} was not consolidated",
)
self.assertEqual(
frappe.db.get_value(
"Sales Invoice", pos_invoice.consolidated_invoice, "outstanding_amount"
),
0,
)
for note in returns:
# no returned row may be priced above the row it reverses
for row in frappe.get_all(
"Sales Invoice Item",
filters={"parent": note.consolidated_invoice},
fields=["item_code", "rate", "sales_invoice_item"],
):
self.assertTrue(row.sales_invoice_item, f"{row.item_code} lost its link to the sale")
sold_rate = frappe.db.get_value("Sales Invoice Item", row.sales_invoice_item, "rate")
self.assertLessEqual(row.rate, sold_rate)
# returns for one customer land on a single credit note, which still adds up to
# everything handed back over the counter
refunded = {}
for note in returns:
refunded[note.consolidated_invoice] = refunded.get(note.consolidated_invoice, 0) + flt(
note.grand_total
)
for consolidated_name, handed_back in refunded.items():
self.assertEqual(
flt(frappe.db.get_value("Sales Invoice", consolidated_name, "grand_total"), 2),
flt(handed_back, 2),
)
finally:
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")

View File

@@ -422,7 +422,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2026-02-22 04:17:03.308876",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",
@@ -446,6 +446,10 @@
"read": 1,
"report": 1,
"role": "Accounts User"
},
{
"role": "Sales Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -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"]
@@ -364,6 +387,18 @@ def apply_pricing_rule(args, doc=None):
args = frappe._dict(args)
# an allow-list, not a type check: `doctype` is caller-chosen, and any doctype the caller can
# read would otherwise satisfy the 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)
set_transaction_type(args)
# list of dictionaries
@@ -733,14 +768,18 @@ def make_pricing_rule(doctype, docname):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_item_uoms(doctype, txt, searchfield, start, page_len, filters):
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=["distinct uom"],
as_list=1,
)

View File

@@ -130,14 +130,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)
@@ -154,7 +157,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)
@@ -170,7 +173,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)
@@ -255,8 +258,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")

View File

@@ -293,6 +293,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,
{

View File

@@ -1664,7 +1664,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-08-12 12:00:00.000000",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
@@ -1717,6 +1717,18 @@
"read": 1,
"role": "Accounts Manager",
"write": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -77,7 +77,7 @@
"icon": "fa fa-money",
"idx": 1,
"links": [],
"modified": "2024-01-30 13:08:09.537242",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges Template",
@@ -104,6 +104,22 @@
{
"read": 1,
"role": "Purchase User"
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "Accounts User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"show_title_field_in_link": 1,

View File

@@ -524,6 +524,10 @@ def validate_docs_for_voucher_types(doc_voucher_types):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_repost_allowed_types(doctype, txt, searchfield, start, page_len, filters):
# `filters` goes straight to db.get_all, so reaching this needs gating rather than typing.
# Reached only from this doctype's own form, which is System-Manager-only.
frappe.has_permission("Repost Accounting Ledger", throw=True)
filters = {"allowed": True}
if txt:

View File

@@ -494,6 +494,7 @@ class SalesInvoice(SellingController):
self.validate_standalone_serial_nos_customer()
self.update_stock_reservation_entries()
self.update_stock_ledger()
self.validate_produced_serial_nos_against_reservation()
self.process_asset_depreciation()
@@ -2590,9 +2591,28 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
@frappe.whitelist()
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
reference_field = "inter_company_invoice_reference"
if doctype == "Purchase Order":
reference_field = "inter_company_order_reference"
# The only two targets this resolves a reference field for. Stating them rejects a caller
# supplied doctype that would otherwise be filtered on a column it does not have.
reference_fields = {
"Purchase Invoice": ("inter_company_invoice_reference", "Sales Invoice", "sales_invoice_item"),
"Purchase Order": ("inter_company_order_reference", "Sales Order", "sales_order_item"),
}
if doctype not in reference_fields:
frappe.throw(_("Invalid doctype {0}").format(doctype), frappe.PermissionError)
reference_field, source_doctype, expected_fieldname = reference_fields[doctype]
# the source document decides access, not the targets: those belong to the counterpart company
# and the caller legitimately may not read them. doc= for User Permissions.
frappe.has_permission(source_doctype, doc=reference_name, throw=True)
# `reference_fieldname` becomes a selected column and the result key, so it has to be this
# target's own reference field: any other item-table column would be returned from unauthorised rows.
if reference_fieldname != expected_fieldname:
frappe.throw(
_("{0} is not a valid reference field for {1}").format(reference_fieldname, doctype),
frappe.ValidationError,
)
filters = {
reference_field: reference_name,

View File

@@ -47,7 +47,7 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
create_stock_reconciliation,
)
from erpnext.stock.get_item_details import get_item_tax_map
from erpnext.stock.utils import get_incoming_rate, get_stock_balance
from erpnext.stock.utils import _get_incoming_rate, get_stock_balance
class TestSalesInvoice(FrappeTestCase):
@@ -2943,7 +2943,7 @@ class TestSalesInvoice(FrappeTestCase):
rate = 0.0
for d in si.get("items"):
rate = get_incoming_rate(
rate = _get_incoming_rate(
{
"item_code": d.item_code,
"warehouse": d.warehouse,
@@ -5042,6 +5042,34 @@ class TestSalesInvoice(FrappeTestCase):
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
@change_settings("Stock Settings", {"enable_stock_reservation": 1})
def test_update_stock_restricted_to_reserved_produced_serial_nos(self):
from erpnext.selling.doctype.sales_order.sales_order import (
make_sales_invoice as make_si_from_so,
)
from erpnext.stock.doctype.delivery_note.test_delivery_note import (
make_so_with_reserved_produced_serial_no,
)
so, reserved, unreserved = make_so_with_reserved_produced_serial_no()
def make_si(serial_no):
si = make_si_from_so(so.name)
si.update_stock = 1
si.items[0].warehouse = so.items[0].warehouse
si.items[0].use_serial_batch_fields = 1
si.items[0].serial_no = serial_no
return si.save()
frappe.db.savepoint("unreserved_serial_no")
si = make_si(unreserved[0])
self.assertRaises(frappe.ValidationError, si.submit)
frappe.db.rollback(save_point="unreserved_serial_no")
si = make_si(reserved[0])
si.submit()
self.assertEqual(get_serial_nos_from_bundle(si.items[0].serial_and_batch_bundle), reserved)
def make_item_for_si(item_code, properties=None):
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -79,7 +79,7 @@
"icon": "fa fa-money",
"idx": 1,
"links": [],
"modified": "2024-01-30 13:07:28.801104",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Taxes and Charges Template",
@@ -113,6 +113,26 @@
"role": "Sales Master Manager",
"share": 1,
"write": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"show_title_field_in_link": 1,

View File

@@ -9,10 +9,15 @@ frappe.ui.form.on("Shipping Rule", {
},
company: function (frm) {
if (frm.previous_company !== frm.doc.company) {
frm.previous_company = frm.doc.company;
frm.set_value("account", "");
}
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
},
refresh: function (frm) {
frm.previous_company = frm.doc.company;
frm.set_query("account", function () {
return {
filters: {

View File

@@ -147,7 +147,7 @@
"icon": "fa fa-truck",
"idx": 1,
"links": [],
"modified": "2026-07-22 14:53:27.315435",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
@@ -195,6 +195,38 @@
"set_user_permissions": 1,
"share": 1,
"write": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -52,10 +52,23 @@ class ShippingRule(Document):
# end: auto-generated types
def validate(self):
self.validate_account_company()
self.validate_from_to_values()
self.sort_shipping_rule_conditions()
self.validate_overlapping_shipping_rule_conditions()
def validate_account_company(self):
if not self.company or not self.account:
return
if frappe.get_cached_value("Account", self.account, "company") != self.company:
throw(
_("Shipping Account {0} does not belong to Company {1}").format(
frappe.bold(self.account), frappe.bold(self.company)
),
title=_("Invalid Shipping Account"),
)
def validate_from_to_values(self):
if self.calculate_based_on == "Fixed":
if self.conditions:

View File

@@ -15,6 +15,39 @@ test_records = frappe.get_test_records("Shipping Rule")
class TestShippingRule(unittest.TestCase):
def test_account_company_on_insert(self):
for rule_type in ("Selling", "Buying"):
with self.subTest(shipping_rule_type=rule_type):
shipping_rule = frappe.copy_doc(test_records[0])
shipping_rule.label = f"{rule_type} Delivery"
shipping_rule.shipping_rule_type = rule_type
shipping_rule.company = "_Test Company 1"
shipping_rule.cost_center = None
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
shipping_rule.insert()
def test_account_company_on_update(self):
shipping_rule = frappe.copy_doc(test_records[0])
shipping_rule.label = "Standard Delivery"
shipping_rule.insert()
shipping_rule.company = "_Test Company 1"
shipping_rule.cost_center = None
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
shipping_rule.save()
shipping_rule.reload()
shipping_rule.company = "_Test Company 1"
shipping_rule.account = "_Test Account Shipping Charges - _TC1"
shipping_rule.cost_center = None
shipping_rule.save()
shipping_rule.reload()
self.assertEqual(shipping_rule.company, "_Test Company 1")
self.assertEqual(shipping_rule.account, "_Test Account Shipping Charges - _TC1")
shipping_rule.account = "_Test Account Shipping Charges - _TC"
with self.assertRaisesRegex(frappe.ValidationError, "does not belong to Company"):
shipping_rule.save()
def test_from_greater_than_to(self):
shipping_rule = frappe.copy_doc(test_records[0])
shipping_rule.name = test_records[0].get("name")

View File

@@ -29,7 +29,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-01-18 21:13:41.161017",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Category",
@@ -68,6 +68,62 @@
"report": 1,
"role": "Accounts User",
"share": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Item Manager",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
},
{
"role": "Stock User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -145,6 +145,12 @@ def get_party_details(party, party_type, args=None):
out = {}
billing_address, shipping_address = None, None
if args:
# each of these names a single Address. A dict is read as a filter instead, and `get_doc`
# would resolve it to whichever Address happens to match, so only a plain name is accepted
for fieldname in ("billing_address", "shipping_address"):
if args.get(fieldname) and not isinstance(args.get(fieldname), str):
frappe.throw(_("Invalid address"), frappe.PermissionError)
if args.get("billing_address"):
billing_address = frappe.get_doc("Address", args.get("billing_address"))
if args.get("shipping_address"):

View File

@@ -102,7 +102,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-30 07:13:51.785735",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Withholding Category",
@@ -144,6 +144,26 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Item Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"sort_field": "modified",

View File

@@ -106,6 +106,8 @@ def get_linked_payments_for_doc(
company: str | None = None, doctype: str | None = None, docname: str | None = None
) -> list:
if company and doctype and docname:
frappe.has_permission(doctype, doc=docname, throw=True)
_dt = doctype
_dn = docname
ple = qb.DocType("Payment Ledger Entry")

View File

@@ -5,8 +5,9 @@
import frappe
from frappe import _, msgprint, qb, scrub
from frappe.contacts.doctype.address.address import get_company_address, get_default_address
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.model.utils import get_fetch_values
from frappe.permissions import get_allowed_docs_for_doctype
from frappe.query_builder.functions import Abs, Date, Sum
from frappe.utils import (
add_days,
@@ -156,7 +157,7 @@ def _get_party_details(
)
set_contact_details(party_details, party, party_type)
set_other_values(party_details, party, party_type)
set_price_list(party_details, party, party_type, price_list, pos_profile)
set_price_list(party_details, party, party_type, price_list, pos_profile, doctype)
tax_template = set_taxes(
party.name,
@@ -378,13 +379,33 @@ def get_default_price_list(party):
return frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
def get_permitted_price_lists(doctype=None):
permissions = sorted(
get_user_permissions().get("Price List", []), key=lambda p: p.get("is_default"), reverse=True
)
# a permission applicable for another doctype doesn't restrict this transaction
return get_allowed_docs_for_doctype(permissions, doctype)
def get_usable_price_list(price_lists, party_doctype):
transaction_side = "selling" if party_doctype == "Customer" else "buying"
for price_list in price_lists:
details = frappe.get_cached_value(
"Price List", price_list, ["enabled", transaction_side], as_dict=True
)
if details.enabled and details[transaction_side]:
return price_list
def set_price_list(party_details, party, party_type, given_price_list, pos=None, doctype=None):
# price list
price_list = get_permitted_documents("Price List")
permitted_price_lists = get_permitted_price_lists(doctype)
# if there is only one permitted document based on user permissions, set it
if price_list and len(price_list) == 1:
price_list = price_list[0]
if len(permitted_price_lists) == 1:
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
elif pos and party_type == "Customer":
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
@@ -396,6 +417,10 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
else:
price_list = get_default_price_list(party) or given_price_list
# don't set a price list the user has no permission for, the transaction can't be saved with it
if price_list and permitted_price_lists and price_list not in permitted_price_lists:
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
if price_list:
party_details.price_list_currency = frappe.db.get_value(
"Price List", price_list, "currency", cache=True

View File

@@ -151,7 +151,7 @@ frappe.query_reports["Accounts Payable"] = {
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
label: __("Include Revaluation Journals"),
fieldtype: "Check",
},
{

View File

@@ -113,7 +113,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
label: __("Include Revaluation Journals"),
fieldtype: "Check",
},
{

View File

@@ -178,7 +178,7 @@ frappe.query_reports["Accounts Receivable"] = {
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
label: __("Include Revaluation Journals"),
fieldtype: "Check",
},
{

View File

@@ -141,7 +141,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
label: __("Include Revaluation Journals"),
fieldtype: "Check",
},
],

View File

@@ -16,7 +16,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
)
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate
def execute(filters=None):
@@ -732,23 +732,32 @@ class GrossProfitGenerator:
)
def get_returned_invoice_items(self):
returned_invoices = frappe.db.sql(
"""
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
from
`tabSales Invoice` si, `tabSales Invoice Item` si_item
where
si.name = si_item.parent
and si.docstatus = 1
and si.is_return = 1
and si.posting_date between %(from_date)s and %(to_date)s
""",
{"from_date": self.filters.from_date, "to_date": self.filters.to_date},
as_dict=1,
si = frappe.qb.DocType("Sales Invoice")
si_item = frappe.qb.DocType("Sales Invoice Item")
query = (
frappe.qb.from_(si)
.inner_join(si_item)
.on(si.name == si_item.parent)
.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,
)
.where(
(si.docstatus == 1)
& (si.is_return == 1)
& si.posting_date.between(self.filters.from_date, self.filters.to_date)
)
)
if self.filters.company:
query = query.where(si.company == self.filters.company)
returned_invoices = query.run(as_dict=1)
self.returned_invoices = frappe._dict()
self.legacy_returned_invoices = frappe._dict()
for inv in returned_invoices:
@@ -964,7 +973,7 @@ class GrossProfitGenerator:
if row.serial_and_batch_bundle:
args.update({"serial_and_batch_bundle": row.serial_and_batch_bundle})
average_buying_rate = get_incoming_rate(args)
average_buying_rate = _get_incoming_rate(args)
self.average_buying_rate[key] = flt(average_buying_rate)
return self.average_buying_rate[key]
@@ -1347,7 +1356,4 @@ class GrossProfitGenerator:
).setdefault(d.parent_item, []).append(d)
def load_non_stock_items(self):
self.non_stock_items = frappe.db.sql_list(
"""select name from tabItem
where is_stock_item=0"""
)
self.non_stock_items = frappe.get_all("Item", filters={"is_stock_item": 0}, pluck="name")

View File

@@ -1,7 +1,8 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.accounts.party import get_default_price_list
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.party import get_default_price_list, set_price_list
class PartyTestCase(FrappeTestCase):
@@ -12,3 +13,170 @@ class PartyTestCase(FrappeTestCase):
customer.save()
price_list = get_default_price_list(customer)
assert price_list is None
def test_fallback_should_not_pick_an_unpermitted_price_list(self):
permitted_default = self.create_price_list(enabled=1)
permitted_other = self.create_price_list(enabled=1)
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(
party_details, customer, "Customer", self.create_price_list(enabled=1), doctype="Sales Order"
)
self.assertEqual(party_details.selling_price_list, permitted_default)
def test_permitted_given_price_list_should_be_kept(self):
permitted_default = self.create_price_list(enabled=1)
permitted_other = self.create_price_list(enabled=1)
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", permitted_other, doctype="Sales Order")
self.assertEqual(party_details.selling_price_list, permitted_other)
def test_permission_for_another_doctype_should_not_apply(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_a_single_permitted_price_list_should_fit_the_transaction(self):
buying_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
user = self.create_user_with_price_list_permissions([buying_price_list])
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertIsNone(party_details.selling_price_list)
def test_buying_transaction_should_not_take_a_selling_price_list(self):
permitted = [self.create_price_list(enabled=1) for _ in range(2)]
user = self.create_user_with_price_list_permissions(permitted)
supplier_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
supplier = self.create_supplier(default_price_list=supplier_price_list)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, supplier, "Supplier", None, doctype="Purchase Order")
self.assertIsNone(party_details.buying_price_list)
def test_permission_for_another_doctype_should_not_apply_without_a_doctype(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list)
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_pos_price_list_should_be_kept(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted)
pos_price_list = self.create_price_list(enabled=1)
pos_profile = make_pos_profile(selling_price_list=pos_price_list)
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(
party_details, customer, "Customer", None, pos=pos_profile.name, doctype="POS Invoice"
)
self.assertEqual(party_details.selling_price_list, pos_price_list)
def test_disabled_permitted_price_lists_should_clear_the_price_list(self):
permitted = [self.create_price_list(enabled=0), self.create_price_list(enabled=0)]
user = self.create_user_with_price_list_permissions(permitted)
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertIsNone(party_details.selling_price_list)
def create_user_with_price_list_permissions(self, price_lists, applicable_for=None):
user = frappe.get_doc(
{
"doctype": "User",
"email": f"{frappe.generate_hash(length=10)}@example.com",
"first_name": "Price List Test",
"send_welcome_email": 0,
"roles": [{"role": "Sales User"}],
}
).insert(ignore_permissions=True)
for idx, price_list in enumerate(price_lists):
frappe.get_doc(
{
"doctype": "User Permission",
"user": user.name,
"allow": "Price List",
"for_value": price_list,
"is_default": int(idx == 0),
"apply_to_all_doctypes": int(not applicable_for),
"applicable_for": applicable_for,
}
).insert(ignore_permissions=True)
frappe.clear_cache(user=user.name)
self.addCleanup(frappe.clear_cache, user=user.name)
return user.name
def create_price_list(self, enabled, selling=1, buying=0):
price_list = frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": selling,
"buying": buying,
"enabled": enabled,
}
).insert(ignore_permissions=True)
return price_list.name
def create_supplier(self, **values):
return frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": frappe.generate_hash(length=10),
**values,
}
).insert(ignore_permissions=True, ignore_mandatory=True)
def create_customer(self, **values):
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": frappe.generate_hash(length=10),
"customer_group": "Individual",
**values,
}
).insert(ignore_permissions=True, ignore_mandatory=True)
customer.customer_group = None
customer.save()
return customer

View File

@@ -597,7 +597,7 @@
"link_fieldname": "target_asset"
}
],
"modified": "2026-03-13 12:15:25.734623",
"modified": "2026-09-23 12:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset",
@@ -633,6 +633,38 @@
"share": 1,
"submit": 1,
"write": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
},
{
"role": "Stock User",
"select": 1
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "System Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -1211,6 +1211,22 @@ def is_cwip_accounting_enabled(asset_category):
@frappe.whitelist()
def get_asset_value_after_depreciation(asset_name, finance_book=None):
# one of the three calling forms is the boundary; Asset itself excludes the roles holding Asset Value Adjustment write
if not any(
frappe.has_permission(dt, "write")
for dt in ("Asset Value Adjustment", "Asset Capitalization", "Asset Repair")
):
frappe.throw(_("Not permitted"), frappe.PermissionError)
# select-or-read: these roles hold `select` on Asset, which does not satisfy a `read` check.
# Guard only here -- the in-process callers use _get_asset_value_after_depreciation() below.
ptype = "select" if frappe.only_has_select_perm("Asset") else "read"
frappe.has_permission("Asset", ptype, doc=asset_name, throw=True)
return _get_asset_value_after_depreciation(asset_name, finance_book)
def _get_asset_value_after_depreciation(asset_name, finance_book=None):
asset = frappe.get_doc("Asset", asset_name)
if not asset.calculate_depreciation:
return flt(asset.value_after_depreciation)
@@ -1220,6 +1236,8 @@ def get_asset_value_after_depreciation(asset_name, finance_book=None):
@frappe.whitelist()
def has_active_capitalization(asset):
frappe.has_permission("Asset", doc=asset, throw=True)
active_capitalizations = frappe.db.count(
"Asset Capitalization", filters={"target_asset": asset, "docstatus": 1}
)
@@ -1228,6 +1246,15 @@ def has_active_capitalization(asset):
@frappe.whitelist()
def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
# `doctype` is caller-supplied and reaches frappe.get_doc(), so without this list any document
# with an `items` table could be read for its valuation rates
if doctype not in ("Purchase Receipt", "Purchase Invoice"):
frappe.throw(_("Invalid document type"), frappe.PermissionError)
# the Asset form is the boundary: Quality Manager writes Assets but reads neither Purchase
# Receipt nor Purchase Invoice, so the purchase document cannot be it
frappe.has_permission("Asset", "write", throw=True)
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]

View File

@@ -10,7 +10,7 @@ from frappe import _
from frappe.utils import cint, flt, get_link_to_form
import erpnext
from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.asset import _get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.depreciation import (
depreciate_asset,
get_gl_entries_on_asset_disposal,
@@ -31,7 +31,7 @@ from erpnext.stock.get_item_details import (
get_item_warehouse,
)
from erpnext.stock.stock_ledger import get_previous_sle
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate
force_fields = [
"target_item_name",
@@ -205,7 +205,7 @@ class AssetCapitalization(StockController):
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_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
@@ -358,6 +358,8 @@ class AssetCapitalization(StockController):
@frappe.whitelist()
def set_warehouse_details(self):
self.check_permission("write")
for d in self.get("stock_items"):
if d.item_code and d.warehouse:
args = self.get_args_for_incoming_rate(d)
@@ -368,11 +370,13 @@ class AssetCapitalization(StockController):
@frappe.whitelist()
def set_asset_values(self):
self.check_permission("write")
for d in self.get("asset_items"):
if d.asset:
finance_book = d.get("finance_book") or self.get("finance_book")
d.current_asset_value = flt(
get_asset_value_after_depreciation(d.asset, finance_book=finance_book)
_get_asset_value_after_depreciation(d.asset, finance_book=finance_book)
)
d.asset_value = get_value_after_depreciation_on_disposal_date(
d.asset, self.posting_date, finance_book=finance_book
@@ -696,8 +700,15 @@ class AssetCapitalization(StockController):
)
def check_capitalization_access():
"""Every lookup in this file feeds the Asset Capitalization form, so that form is the boundary."""
frappe.has_permission("Asset Capitalization", throw=True)
@frappe.whitelist()
def get_target_item_details(item_code=None, company=None):
check_capitalization_access()
out = frappe._dict()
# Get Item Details
@@ -735,6 +746,8 @@ def get_target_item_details(item_code=None, company=None):
@frappe.whitelist()
def get_target_asset_details(asset=None, company=None):
check_capitalization_access()
out = frappe._dict()
# Get Asset Details
@@ -829,17 +842,25 @@ def get_warehouse_details(args):
frappe.has_permission("Stock Ledger Entry", throw=True)
out = {
"actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0,
"valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False),
"valuation_rate": _get_incoming_rate(args, raise_error_if_no_rate=False),
}
return out
@frappe.whitelist()
def get_consumed_asset_details(args):
check_capitalization_access()
if isinstance(args, str):
args = json.loads(args)
args = frappe._dict(args)
# and the Asset the caller named: its depreciation values are returned through the unguarded
# _get_asset_value_after_depreciation. select-or-read, as in the asset.py wrapper.
if args.get("asset"):
ptype = "select" if frappe.only_has_select_perm("Asset") else "read"
frappe.has_permission("Asset", ptype, doc=args.get("asset"), throw=True)
out = frappe._dict()
asset_details = frappe._dict()
@@ -856,7 +877,7 @@ def get_consumed_asset_details(args):
if args.asset:
out.current_asset_value = flt(
get_asset_value_after_depreciation(args.asset, finance_book=args.finance_book)
_get_asset_value_after_depreciation(args.asset, finance_book=args.finance_book)
)
out.asset_value = get_value_after_depreciation_on_disposal_date(
args.asset, args.posting_date, finance_book=args.finance_book
@@ -885,6 +906,8 @@ def get_consumed_asset_details(args):
@frappe.whitelist()
def get_service_item_details(args):
check_capitalization_access()
if isinstance(args, str):
args = json.loads(args)
@@ -913,6 +936,8 @@ def get_service_item_details(args):
@frappe.whitelist()
def get_items_tagged_to_wip_composite_asset(params):
check_capitalization_access()
if isinstance(params, str):
params = json.loads(params)

View File

@@ -73,7 +73,7 @@
}
],
"links": [],
"modified": "2025-05-13 15:33:03.791814",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Category",
@@ -116,6 +116,10 @@
"role": "Quality Manager",
"share": 1,
"write": 1
},
{
"role": "Item Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -61,7 +61,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-01-22 15:09:03.347345",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Maintenance Team",
@@ -78,6 +78,10 @@
"role": "Manufacturing User",
"share": 1,
"write": 1
},
{
"role": "Quality Manager",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -36,7 +36,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-29 04:04:24.272872",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Shift Factor",
@@ -66,6 +66,10 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Quality Manager",
"select": 1
}
],
"sort_field": "modified",

View File

@@ -10,7 +10,7 @@ from frappe.utils import cstr, flt, formatdate, get_link_to_form, getdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_checks_for_pl_and_bs_accounts,
)
from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.asset import _get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
@@ -81,7 +81,7 @@ class AssetValueAdjustment(Document):
def set_current_asset_value(self):
if not self.current_asset_value and self.asset:
self.current_asset_value = get_asset_value_after_depreciation(self.asset, self.finance_book)
self.current_asset_value = _get_asset_value_after_depreciation(self.asset, self.finance_book)
def make_depreciation_entry(self):
asset = frappe.get_doc("Asset", self.asset)

View File

@@ -142,7 +142,7 @@
],
"is_tree": 1,
"links": [],
"modified": "2023-08-29 12:49:33.290527",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Location",
@@ -219,6 +219,10 @@
"role": "Agriculture User",
"share": 1,
"write": 1
},
{
"role": "Quality Manager",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -15,7 +15,7 @@ from erpnext.accounts.report.financial_statements import (
validate_fiscal_year,
)
from erpnext.accounts.utils import get_fiscal_year
from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.asset import _get_asset_value_after_depreciation
def execute(filters=None):

View File

@@ -699,14 +699,19 @@ def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=
return item_last_purchase_rate
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def close_or_unclose_purchase_orders(names, status):
if not frappe.has_permission("Purchase Order", "write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.has_permission("Purchase Order", "write", throw=True)
names = json.loads(names)
for name in names:
if not isinstance(name, str):
frappe.throw(_("Invalid name"), frappe.PermissionError)
# the check above is doctype level and never consults User Permissions, so on its own it lets
# a caller restricted to one company close another company's orders
po = frappe.get_doc("Purchase Order", name)
po.check_permission("submit")
if po.docstatus == 1:
if status == "Closed":
if po.status not in ("Cancelled", "Closed") and (

View File

@@ -11,7 +11,6 @@ from frappe.core.doctype.communication.email import make
from frappe.desk.form.load import get_attachments
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Order
from frappe.utils import get_url
from frappe.utils.print_format import download_pdf
from frappe.utils.user import get_user_fullname
@@ -683,32 +682,34 @@ def get_supplier_tag():
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_rfq_containing_supplier(doctype, txt, searchfield, start, page_len, filters):
rfq = frappe.qb.DocType("Request for Quotation")
rfq_supplier = frappe.qb.DocType("Request for Quotation Supplier")
rfq_filters = [
["docstatus", "=", 1],
["company", "=", filters.get("company")],
]
query = (
frappe.qb.from_(rfq)
.from_(rfq_supplier)
.select(rfq.name)
.distinct()
.select(rfq.transaction_date, rfq.company)
.where(
(rfq.name == rfq_supplier.parent)
& (rfq_supplier.supplier == filters.get("supplier"))
& (rfq.docstatus == 1)
& (rfq.company == filters.get("company"))
if frappe.has_permission("Request for Quotation", "read"):
rfq_filters.append(["Request for Quotation Supplier", "supplier", "=", filters.get("supplier")])
else:
parents = frappe.get_all(
"Request for Quotation Supplier",
filters={"supplier": filters.get("supplier"), "parenttype": "Request for Quotation"},
pluck="parent",
distinct=True,
)
.orderby(rfq.transaction_date, order=Order.asc)
.limit(page_len)
.offset(start)
)
rfq_filters.append(["name", "in", parents or [""]])
if txt:
query = query.where(rfq.name.like(f"%%{txt}%%"))
rfq_filters.append(["name", "like", f"%{txt}%"])
if filters.get("transaction_date"):
query = query.where(rfq.transaction_date == filters.get("transaction_date"))
rfq_filters.append(["transaction_date", "=", filters.get("transaction_date")])
rfq_data = query.run(as_dict=1)
return rfq_data
return frappe.get_list(
"Request for Quotation",
filters=rfq_filters,
fields=["name", "transaction_date", "company"],
group_by="name",
order_by="transaction_date asc",
limit_start=start,
limit_page_length=page_len,
)

View File

@@ -491,7 +491,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-05-29 16:52:59.441272",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",
@@ -547,6 +547,30 @@
"read": 1,
"report": 1,
"role": "Accounts Manager"
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Website Manager",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -233,6 +233,15 @@ class Supplier(TransactionBase):
def get_supplier_primary(doctype, txt, searchfield, start, page_len, filters):
supplier = filters.get("supplier")
type = filters.get("type")
# `type` is caller-supplied and was interpolated into qb.DocType(), so any doctype could be
# joined to Dynamic Link and read. The two pickers send only these values.
if type not in ("Contact", "Address"):
frappe.throw(_("Invalid type"), frappe.PermissionError)
# authorise the party, not Contact/Address: the `if_owner` row on Address would empty the picker rather than error
frappe.has_permission("Supplier", doc=supplier, throw=True)
type_doctype = frappe.qb.DocType(type)
dynamic_link = frappe.qb.DocType("Dynamic Link")

View File

@@ -939,7 +939,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-08-12 12:00:00.000000",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",
@@ -998,6 +998,14 @@
"read": 1,
"role": "Purchase Manager",
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -55,8 +55,7 @@ class SupplierScorecard(Document):
self.update_standing()
def on_update(self):
score = make_all_scorecards(self.name)
if score > 0:
if make_all_scorecards(self.name) > 0:
self.save()
def validate_standings(self):
@@ -198,7 +197,7 @@ def refresh_scorecards():
@frappe.whitelist()
def make_all_scorecards(docname):
def make_all_scorecards(docname: str):
sc = frappe.get_doc("Supplier Scorecard", docname)
supplier = frappe.get_doc("Supplier", sc.supplier)
supplier.check_permission("write")
@@ -213,25 +212,18 @@ def make_all_scorecards(docname):
while (start_date < todays) and (end_date <= todays):
# check to make sure there is no scorecard period already created
scorecards = frappe.db.sql(
"""
SELECT
scp.name
FROM
`tabSupplier Scorecard Period` scp
WHERE
scp.scorecard = %(sc)s
AND scp.docstatus = 1
AND (
(scp.start_date > %(end_date)s
AND scp.end_date < %(start_date)s)
OR
(scp.start_date < %(end_date)s
AND scp.end_date > %(start_date)s))
ORDER BY
scp.end_date DESC""",
{"sc": docname, "start_date": start_date, "end_date": end_date},
as_dict=1,
# (inclusive bounds: a single-day period — supplier created on a month's
# last day — must match its own window, else it is re-created every run)
scorecards = frappe.get_all(
"Supplier Scorecard Period",
fields=["name"],
filters={
"scorecard": docname,
"docstatus": 1,
"start_date": ["<=", end_date],
"end_date": [">=", start_date],
},
order_by="end_date desc",
)
if len(scorecards) == 0:
period_card = make_supplier_scorecard(docname, None)

View File

@@ -4,6 +4,9 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_months, get_last_day, getdate
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
class TestSupplierScorecard(FrappeTestCase):
@@ -18,6 +21,32 @@ class TestSupplierScorecard(FrappeTestCase):
d.weight = 0
self.assertRaises(frappe.ValidationError, my_doc.insert)
def test_no_recursion_for_supplier_created_on_month_end(self):
make_supplier_scorecard() # ensures the "Delivery" criteria master exists
supplier = create_supplier(supplier_name="_Test Month End Scorecard Supplier")
month_end = get_last_day(add_months(getdate(), -1))
frappe.db.set_value("Supplier", supplier.name, "creation", month_end, update_modified=False)
scorecard = frappe.get_doc(valid_scorecard[0])
scorecard.supplier = supplier.name
scorecard.name = supplier.name
scorecard.insert()
periods = frappe.get_all(
"Supplier Scorecard Period",
filters={"scorecard": scorecard.name},
fields=["start_date", "end_date"],
)
self.assertEqual(len(periods), 1)
self.assertEqual(periods[0].start_date, month_end)
self.assertEqual(periods[0].end_date, month_end)
# saving again must not re-create the single-day period or recurse
frappe.get_doc("Supplier Scorecard", scorecard.name).save()
periods = frappe.get_all("Supplier Scorecard Period", filters={"scorecard": scorecard.name})
self.assertEqual(len(periods), 1)
def make_supplier_scorecard():
my_doc = frappe.get_doc(valid_scorecard[0])

View File

@@ -43,7 +43,9 @@ def update_last_purchase_rate(doc, is_submit) -> None:
frappe.throw(_("UOM Conversion factor is required in row {0}").format(d.idx))
# update last purchsae rate
frappe.db.set_value("Item", d.item_code, "last_purchase_rate", flt(last_purchase_rate))
frappe.db.set_value(
"Item", d.item_code, "last_purchase_rate", flt(last_purchase_rate), update_modified=False
)
def validate_for_items(doc) -> None:
@@ -118,7 +120,35 @@ def check_on_hold_or_closed_status(doctype, docname) -> None:
@frappe.whitelist()
def get_linked_material_requests(items):
items = json.loads(items)
try:
items = frappe.parse_json(items)
except (TypeError, ValueError):
frappe.throw(_("Items must be a list of Item codes"))
if isinstance(items, str):
items = [items]
if not isinstance(items, list | tuple) or any(not isinstance(item, str) for item in items):
frappe.throw(_("Items must be a list of Item codes"))
# get_list, not the raw query below on its own: it applies the caller's Material Request
# permission and their User Permissions, so the loop can only ever return permitted documents
permitted_material_requests = frappe.get_list(
"Material Request",
filters=[
["material_request_type", "=", "Purchase"],
["docstatus", "=", 1],
["status", "!=", "Stopped"],
["per_ordered", "<", 99.99],
["Material Request Item", "item_code", "in", items],
],
pluck="name",
distinct=True,
)
if not permitted_material_requests:
return []
mr_list = []
for item in items:
material_request = frappe.db.sql(
@@ -129,12 +159,13 @@ def get_linked_material_requests(items):
FROM `tabMaterial Request` mr, `tabMaterial Request Item` mr_item
WHERE mr.name = mr_item.parent
AND mr_item.item_code = %(item)s
AND mr.name in %(permitted)s
AND mr.material_request_type = 'Purchase'
AND mr.per_ordered < 99.99
AND mr.docstatus = 1
AND mr.status != 'Stopped'
ORDER BY mr_item.item_code ASC""",
{"item": item},
{"item": item, "permitted": permitted_material_requests},
as_dict=1,
)
if material_request:

View File

@@ -387,12 +387,26 @@ class AccountsController(TransactionBase):
return any(item.delivered_by_supplier for item in items)
def validate_price_list(self):
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
if self.get("selling_price_list"):
price_list_field, transaction_side = "selling_price_list", "selling"
else:
price_list_field, transaction_side = "buying_price_list", "buying"
price_list = self.get(price_list_field)
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
if not price_list:
return
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
details = (
frappe.db.get_value("Price List", price_list, ["enabled", transaction_side], as_dict=True)
or frappe._dict()
)
# An internal transfer carries the price list of the outward document into the inward one.
fits_transaction = details.get(transaction_side) or self.is_internal_transfer()
if details.enabled and fits_transaction:
return
# Returns retain a submitted voucher's pricing even if its price list no longer fits.
if (
self.get("is_return")
and self.get("return_against")
@@ -403,9 +417,20 @@ class AccountsController(TransactionBase):
):
return
if not details.enabled:
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
if transaction_side == "selling":
message = _("Price List {0} cannot be used on a selling transaction")
else:
message = _("Price List {0} cannot be used on a buying transaction")
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
message.format(get_link_to_form("Price List", price_list)),
title=_("Invalid Price List"),
)
def set_default_letter_head(self):
@@ -3199,11 +3224,22 @@ def get_tax_rate(account_head):
return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
# the only doctypes a `taxes_and_charges` Link points at; `master_doctype` is caller-supplied and reaches get_doc()
TAX_MASTER_DOCTYPES = ("Sales Taxes and Charges Template", "Purchase Taxes and Charges Template")
def validate_tax_master(master_doctype):
if master_doctype not in TAX_MASTER_DOCTYPES:
frappe.throw(_("Invalid tax master doctype"), frappe.PermissionError)
@frappe.whitelist()
def get_default_taxes_and_charges(master_doctype, tax_template=None, company=None):
if not company:
return {}
validate_tax_master(master_doctype)
if tax_template and company:
tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company")
if tax_template_company == company:
@@ -3221,6 +3257,9 @@ def get_default_taxes_and_charges(master_doctype, tax_template=None, company=Non
def get_taxes_and_charges(master_doctype, master_name):
if not master_name:
return
validate_tax_master(master_doctype)
from frappe.model import child_table_fields, default_fields
tax_master = frappe.get_doc(master_doctype, master_name)

View File

@@ -17,7 +17,7 @@ from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.stock.get_item_details import get_conversion_factor
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate
class QtyMismatchError(ValidationError):
@@ -136,7 +136,7 @@ class BuyingController(SubcontractingController):
for row in self.items:
if row.rate <= 0:
# override the rate with valuation rate
row.rate = get_incoming_rate(
row.rate = _get_incoming_rate(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
@@ -366,7 +366,7 @@ class BuyingController(SubcontractingController):
net_rate = item.base_net_amount
if item.sales_incoming_rate: # for internal transfer
net_rate = item.qty * item.sales_incoming_rate
net_rate = self.get_internal_transfer_qty(item) * item.sales_incoming_rate
if (
not net_rate
@@ -459,7 +459,7 @@ class BuyingController(SubcontractingController):
if not posting_time:
posting_time = nowtime()
outgoing_rate = get_incoming_rate(
outgoing_rate = _get_incoming_rate(
{
"item_code": d.item_code,
"warehouse": d.get("from_warehouse"),
@@ -572,6 +572,26 @@ class BuyingController(SubcontractingController):
if status in ("Closed", "On Hold"):
frappe.throw(_("{0} {1} is {2}").format(ref_doctype, d.get(ref_fieldname), status))
def get_internal_transfer_qty(self, row) -> float:
if flt(row.qty) or not self.is_internal_receipt():
return flt(row.qty)
return flt(row.rejected_qty)
def is_internal_receipt(self) -> bool:
return self.doctype == "Purchase Receipt" and self.is_internal_transfer()
def get_source_warehouse_qty(self, row, accepted_qty):
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return accepted_qty
if row.get("serial_and_batch_bundle") or row.get("rejected_serial_and_batch_bundle"):
return accepted_qty
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
return flt(accepted_qty + rejected_qty, row.precision("stock_qty"))
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
self.update_ordered_and_reserved_qty()
@@ -584,8 +604,9 @@ class BuyingController(SubcontractingController):
if d.warehouse:
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
source_qty = self.get_source_warehouse_qty(d, pr_qty)
if pr_qty:
if pr_qty or source_qty:
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 1)
or (cint(self.is_return) and self.docstatus == 2)
@@ -601,7 +622,7 @@ class BuyingController(SubcontractingController):
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"outgoing_rate": d.rate,
"recalculate_rate": 1,
@@ -674,7 +695,7 @@ class BuyingController(SubcontractingController):
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"recalculate_rate": 1,
"serial_and_batch_bundle": (

View File

@@ -34,6 +34,10 @@ def get_variant(template, args=None, variant=None, manufacturer=None, manufactur
:param item: Template Item
:param args: A dictionary with "Attribute" as key and "Attribute Value" as value
"""
# the template Item is the boundary and `read` is loser-free: the roles that cannot read Item
# cannot open the calling form either
frappe.has_permission("Item", doc=template, throw=True)
item_template = frappe.get_doc("Item", template)
if item_template.variant_based_on == "Manufacturer" and manufacturer:
@@ -309,6 +313,11 @@ def find_variant(template, args, variant_item_code=None):
@frappe.whitelist()
def create_variant(item, args, use_template_image=False):
# same right its sibling enqueue_multiple_variant_creation requires, plus record-level read on
# the template it copies from
frappe.has_permission("Item", ptype="create", throw=True)
frappe.has_permission("Item", doc=item, throw=True)
use_template_image = frappe.parse_json(use_template_image)
if isinstance(args, str):
args = json.loads(args)
@@ -334,7 +343,7 @@ def create_variant(item, args, use_template_image=False):
return variant
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def enqueue_multiple_variant_creation(item, args, use_template_image=False):
frappe.has_permission("Item", ptype="create", throw=True)
use_template_image = frappe.parse_json(use_template_image)
@@ -527,6 +536,10 @@ def make_variant_item_code(template_item_code, template_item_name, variant):
@frappe.whitelist()
def create_variant_doc_for_quick_entry(template, args):
# Delegates to get_variant and create_variant below, which carry their own checks; this one
# fails fast rather than relying on that delegation.
frappe.has_permission("Item", doc=template, throw=True)
variant_based_on = frappe.db.get_value("Item", template, "variant_based_on")
args = json.loads(args)
if variant_based_on == "Manufacturer":

View File

@@ -6,7 +6,7 @@ import json
from collections import OrderedDict, defaultdict
import frappe
from frappe import qb, scrub
from frappe import _, qb, scrub
from frappe.desk.reportview import get_filters_cond, get_match_cond
from frappe.permissions import has_permission
from frappe.query_builder import Criterion, CustomFunction
@@ -310,43 +310,61 @@ def get_project_name(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
):
proj = qb.DocType("Project")
qb_filter_and_conditions = []
qb_filter_or_conditions = []
meta = frappe.get_meta(doctype)
ifelse = CustomFunction("IF", ["condition", "then", "else"])
list_filters = [["status", "not in", ["Completed", "Cancelled", "On hold"]]]
if filters:
if filters.get("customer"):
qb_filter_and_conditions.append(
(proj.customer == filters.get("customer")) | (proj.customer.isnull()) | (proj.customer == "")
)
# an `in` containing "" renders as `ifnull(customer,'') in (...)`: this customer, or none
list_filters.append(["customer", "in", [filters.get("customer"), ""]])
if filters.get("company"):
qb_filter_and_conditions.append(proj.company == filters.get("company"))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
q = qb.from_(proj)
fields = get_fields(doctype, ["name", "project_name"])
for x in fields:
q = q.select(proj[x])
list_filters.append(["company", "=", filters.get("company")])
# don't consider 'customer' and 'status' fields for pattern search, as they must be exactly matched
# permlevel fields go too: get_list refuses to filter on one, which would fail the whole call
searchfields = [
x for x in frappe.get_meta(doctype).get_search_fields() if x not in ["customer", "status"]
x
for x in meta.get_search_fields()
if x not in ["customer", "status"] and not (meta.get_field(x) and meta.get_field(x).permlevel)
]
# pattern search
if txt:
for x in searchfields:
qb_filter_or_conditions.append(proj[x].like(f"%{txt}%"))
fields = get_fields(doctype, ["name", "project_name"])
q = q.where(Criterion.all(qb_filter_and_conditions)).where(Criterion.any(qb_filter_or_conditions))
# get_list applies the doctype check and the caller's record-level conditions
if not txt:
# no search term means no relevance ordering, so the whole query is expressible here and
# stays paginated in SQL rather than materialising every permitted name
return frappe.get_list(
"Project",
filters=list_filters,
fields=fields,
order_by="idx desc, name",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
# ordering
if txt:
# project_name containing search string 'txt' will be given higher precedence
q = q.orderby(ifelse(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name), 99999))
# with a search term, resolve the (already LIKE-narrowed) permitted names and rank them below:
# the relevance ordering is a CASE expression, which `order_by` rejects
permitted = frappe.get_list(
"Project",
filters=list_filters,
or_filters=[[x, "like", f"%{txt}%"] for x in searchfields],
pluck="name",
order_by="",
limit_page_length=0,
)
if not permitted:
return []
q = qb.from_(proj).select(*[proj[fieldname] for fieldname in fields]).where(proj.name.isin(permitted))
# project_name containing search string 'txt' will be given higher precedence
q = q.orderby(ifelse(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name), 99999))
q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name)
if page_len:
@@ -609,26 +627,30 @@ def get_account_list(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
bo = frappe.qb.DocType("Blanket Order")
bo_item = frappe.qb.DocType("Blanket Order Item")
bo_filters = [
["docstatus", "=", 1],
["blanket_order_type", "=", filters.get("blanket_order_type")],
["company", "=", filters.get("company")],
]
blanket_orders = (
frappe.qb.from_(bo)
.from_(bo_item)
.select(bo.name)
.distinct()
.select(bo.blanket_order_type, bo.to_date)
.where(
(bo_item.parent == bo.name)
& (bo_item.item_code == filters.get("item"))
& (bo.blanket_order_type == filters.get("blanket_order_type"))
& (bo.company == filters.get("company"))
& (bo.docstatus == 1)
if frappe.has_permission("Blanket Order", "read"):
bo_filters.append(["Blanket Order Item", "item_code", "=", filters.get("item")])
else:
parents = frappe.get_all(
"Blanket Order Item",
filters={"item_code": filters.get("item"), "parenttype": "Blanket Order"},
pluck="parent",
distinct=True,
)
.run()
)
bo_filters.append(["name", "in", parents or [""]])
return blanket_orders
return frappe.get_list(
"Blanket Order",
filters=bo_filters,
fields=["name", "blanket_order_type", "to_date"],
group_by="name",
as_list=True,
)
@frappe.whitelist()
@@ -810,15 +832,22 @@ def get_doctype_wise_filters(filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_batch_numbers(doctype, txt, searchfield, start, page_len, filters):
query = """select batch_id from `tabBatch`
where disabled = 0
and (expiry_date >= CURRENT_DATE or expiry_date IS NULL)
and name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
# get_list applies the select check and the caller's record-level conditions together
batch_filters = [["disabled", "=", 0], ["name", "like", f"%{txt}%"]]
if filters and filters.get("item"):
query += " and item = {item}".format(item=frappe.db.escape(filters.get("item")))
batch_filters.append(["item", "=", filters.get("item")])
return frappe.db.sql(query, filters)
return frappe.get_list(
"Batch",
filters=batch_filters,
or_filters=[["expiry_date", "is", "not set"], ["expiry_date", ">=", today()]],
fields=["batch_id"],
order_by="batch_id",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
@frappe.whitelist()
@@ -843,35 +872,69 @@ def item_manufacturer_query(doctype, txt, searchfield, start, page_len, filters)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_purchase_receipts(doctype, txt, searchfield, start, page_len, filters):
query = """
select pr.name
from `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pritem
where pr.docstatus = 1 and pritem.parent = pr.name
and pr.name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
pr_filters = [["docstatus", "=", 1], ["name", "like", f"%{txt}%"]]
if filters and filters.get("item_code"):
query += " and pritem.item_code = {item_code}".format(
item_code=frappe.db.escape(filters.get("item_code"))
)
if frappe.has_permission("Purchase Receipt", "read"):
# one indexed join, deduped by group_by below
pr_filters.append(["Purchase Receipt Item", "item_code", "=", filters.get("item_code")])
else:
# a select-only caller may use this picker but may not filter on a child table, so resolve
# the parents separately rather than losing the filter to a PermissionError
parents = frappe.get_all(
"Purchase Receipt Item",
filters={"item_code": filters.get("item_code"), "parenttype": "Purchase Receipt"},
pluck="parent",
distinct=True,
)
pr_filters.append(["name", "in", parents or [""]])
return frappe.db.sql(query, filters)
# group_by, not distinct: it dedupes the child join just the same, and frappe drops ORDER BY
# from a distinct query on Postgres
return frappe.get_list(
"Purchase Receipt",
filters=pr_filters,
fields=["name"],
group_by="name",
order_by="name",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_purchase_invoices(doctype, txt, searchfield, start, page_len, filters):
query = """
select pi.name
from `tabPurchase Invoice` pi, `tabPurchase Invoice Item` piitem
where pi.docstatus = 1 and piitem.parent = pi.name
and pi.name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
pi_filters = [["docstatus", "=", 1], ["name", "like", f"%{txt}%"]]
if filters and filters.get("item_code"):
query += " and piitem.item_code = {item_code}".format(
item_code=frappe.db.escape(filters.get("item_code"))
)
if frappe.has_permission("Purchase Invoice", "read"):
# one indexed join, deduped by group_by below
pi_filters.append(["Purchase Invoice Item", "item_code", "=", filters.get("item_code")])
else:
# a select-only caller may use this picker but may not filter on a child table, so resolve
# the parents separately rather than losing the filter to a PermissionError
parents = frappe.get_all(
"Purchase Invoice Item",
filters={"item_code": filters.get("item_code"), "parenttype": "Purchase Invoice"},
pluck="parent",
distinct=True,
)
pi_filters.append(["name", "in", parents or [""]])
return frappe.db.sql(query, filters)
# group_by, not distinct: it dedupes the child join just the same, and frappe drops ORDER BY
# from a distinct query on Postgres
return frappe.get_list(
"Purchase Invoice",
filters=pi_filters,
fields=["name"],
group_by="name",
order_by="name",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)
@frappe.whitelist()
@@ -952,9 +1015,28 @@ def get_fields(doctype, fields=None):
def get_payment_terms_for_references(doctype, txt, searchfield, start, page_len, filters) -> list:
terms = []
if filters:
reference = filters.get("reference")
if not reference:
return terms
# only a plain name names one document: a filter operator (["like", "%"], ["!=", ""]) would
# widen this past the document the caller named, and past the one being authorised below
if not isinstance(reference, str):
frappe.throw(_("Invalid reference"), frappe.PermissionError)
# Payment Schedule is a child table with no permissions of its own, so its parent decides access.
# Prefer the caller's reference type: the lookup cannot tell same-named parents of two types apart.
parenttype = filters.get("reference_doctype") or frappe.db.get_value(
"Payment Schedule", {"parent": reference}, "parenttype"
)
if not parenttype:
return terms
frappe.has_permission(parenttype, doc=reference, throw=True)
terms = frappe.db.get_all(
"Payment Schedule",
filters={"parent": filters.get("reference")},
filters={"parent": reference, "parenttype": parenttype},
fields=["payment_term"],
limit=page_len,
as_list=1,
@@ -965,6 +1047,30 @@ def get_payment_terms_for_references(doctype, txt, searchfield, start, page_len,
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters) -> list:
parent = filters.get("parent") if filters else None
# a plain name, never a filter operator: ["like", "%"] here would span parents, and only one
# of them would be the document authorised below
if not parent or not isinstance(parent, str):
frappe.throw(_("Parent document is required to search child rows"), frappe.PermissionError)
# `doctype` is caller supplied, so it has to be a child table before it is worth checking:
# any other doctype would put the caller's filters on a table this query never meant to read
if not frappe.get_meta(doctype).istable:
frappe.throw(_("{0} is not a child table").format(doctype), frappe.PermissionError)
# child tables carry no permissions of their own, so the parent decides access. Read the parent
# type off the rows, not off `filters`, so the document authorised is the one returned.
parenttype = frappe.db.get_value(doctype, {"parent": parent}, "parenttype")
if not parenttype or not frappe.db.exists(parenttype, parent):
return []
frappe.has_permission(doctype, parent_doctype=parenttype, throw=True)
# and on the parent record itself, so that User Permissions still apply
frappe.has_permission(parenttype, doc=parent, throw=True)
table = frappe.qb.DocType(doctype)
query = (
frappe.get_query(table, filters=filters)
@@ -989,7 +1095,11 @@ def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters)
@frappe.validate_and_sanitize_search_inputs
def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
if frappe.db.get_single_value("Stock Settings", "allow_uom_with_conversion_rate_defined_in_item"):
query_filters = {"parent": filters.get("item_code")}
item_code = filters.get("item_code")
if not item_code or not frappe.get_list("Item", filters=[["name", "=", item_code]], pluck="name"):
return []
query_filters = {"parent": item_code, "parenttype": "Item"}
if txt:
query_filters["uom"] = ["like", f"%{txt}%"]
@@ -1004,7 +1114,7 @@ def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
as_list=1,
)
return frappe.get_all(
return frappe.get_list(
"UOM",
filters={"name": ["like", f"%{txt}%"], "enabled": 1},
fields=["name"],

View File

@@ -11,7 +11,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime
import erpnext
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method, getdate
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method, getdate
class StockOverReturnError(frappe.ValidationError):
@@ -736,7 +736,7 @@ def get_rate_for_return(
rate = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "incoming_rate")
if not rate and sle:
rate = get_incoming_rate(
rate = _get_incoming_rate(
{
"item_code": sle.item_code,
"warehouse": sle.warehouse,
@@ -1224,8 +1224,23 @@ def get_available_serial_nos(serial_nos, warehouse):
)
# the only doctypes this endpoint is called for; it reaches get_value()/get_all() as the doctype itself
RETURNABLE_INVOICE_DOCTYPES = ("Sales Invoice", "POS Invoice")
@frappe.whitelist()
def get_payment_data(invoice):
# `invoice` may be either a Sales Invoice or a POS Invoice — both share the Sales Invoice
# Payment child table — so resolve which one it is before authorising rather than guessing.
parenttype = frappe.db.get_value("Sales Invoice Payment", {"parent": invoice}, "parenttype")
if not parenttype:
return []
if parenttype not in RETURNABLE_INVOICE_DOCTYPES:
frappe.throw(_("Invalid document type"), frappe.PermissionError)
frappe.has_permission(parenttype, doc=invoice, throw=True)
payment = frappe.db.get_all("Sales Invoice Payment", {"parent": invoice}, ["mode_of_payment", "amount"])
return payment

View File

@@ -12,7 +12,7 @@ from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, i
from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.item.item import set_item_default
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method
from erpnext.stock.utils import _get_incoming_rate, get_combine_datetime, get_valuation_method
class SellingController(StockController):
@@ -565,7 +565,7 @@ class SellingController(StockController):
or self.is_internal_transfer()
or (get_valuation_method(d.item_code) == "Moving Average" and self.get("is_return"))
):
d.incoming_rate = get_incoming_rate(
d.incoming_rate = _get_incoming_rate(
{
"item_code": d.item_code,
"warehouse": d.warehouse,
@@ -896,6 +896,77 @@ class SellingController(StockController):
title=_("Not Allowed"),
)
def validate_produced_serial_nos_against_reservation(self):
"""Restrict delivery to the serial nos reserved for a Sales Order Item with ensure delivery by serial no."""
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_serial_nos_for_voucher_detail_nos,
)
if self.is_return or not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
return
so_field = "sales_order" if self.doctype == "Sales Invoice" else "against_sales_order"
rows = [d for d in self.items if d.get(so_field) and d.so_detail]
if not rows:
return
flagged_so_details = frappe.get_all(
"Sales Order Item",
filters={
"name": ("in", [d.so_detail for d in rows]),
"ensure_delivery_based_on_produced_serial_no": 1,
},
pluck="name",
)
rows = [d for d in rows if d.so_detail in flagged_so_details]
if not rows:
return
reserved_serial_nos = get_sre_reserved_serial_nos_for_voucher_detail_nos(
"Sales Order", flagged_so_details
)
bundle_map = dict(
frappe.get_all(
rows[0].doctype,
filters={"name": ("in", [d.name for d in rows])},
fields=["name", "serial_and_batch_bundle"],
as_list=True,
)
)
bundle_serial_nos = frappe._dict()
if bundles := [b for b in bundle_map.values() if b]:
for entry in frappe.get_all(
"Serial and Batch Entry",
filters={"parent": ("in", bundles), "serial_no": ("is", "set")},
fields=["parent", "serial_no"],
):
bundle_serial_nos.setdefault(entry.parent, []).append(entry.serial_no)
for row in rows:
if not reserved_serial_nos.get(row.so_detail):
frappe.throw(
_(
"Row #{0}: Delivery of Item {1} is ensured by produced Serial No, but no Serial No is reserved against Sales Order {2}. Reserve the produced Serial Nos from the Sales Order."
).format(row.idx, frappe.bold(row.item_code), frappe.bold(row.get(so_field))),
title=_("Serial No Not Reserved"),
)
bundle = bundle_map.get(row.name)
serial_nos = bundle_serial_nos.get(bundle, []) if bundle else get_serial_nos(row.serial_no)
if invalid_serial_nos := [
sn for sn in serial_nos if sn not in reserved_serial_nos[row.so_detail]
]:
frappe.throw(
_(
"Row #{0}: Serial No {1} is not reserved against Sales Order {2}. Deliver only the Serial Nos produced and reserved for it."
).format(
row.idx, frappe.bold(", ".join(invalid_serial_nos)), frappe.bold(row.get(so_field))
),
title=_("Serial No Not Reserved"),
)
def update_stock_reservation_entries(self) -> None:
"""Updates Delivered Qty in Stock Reservation Entries."""

View File

@@ -20,7 +20,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_nos_from_bundle
from erpnext.stock.utils import get_incoming_rate
from erpnext.stock.utils import _get_incoming_rate
class SubcontractingController(StockController):
@@ -82,7 +82,7 @@ class SubcontractingController(StockController):
}
)
rate = get_incoming_rate(kwargs)
rate = _get_incoming_rate(kwargs)
precision = frappe.get_precision("Subcontracting Receipt Supplied Item", "rate")
if flt(rate, precision) != flt(row.rate, precision):
row.rate = rate
@@ -764,7 +764,7 @@ class SubcontractingController(StockController):
args["batch_no"] = rm_obj.batch_no
args["serial_no"] = rm_obj.serial_no
rm_obj.rate = get_incoming_rate(args)
rm_obj.rate = _get_incoming_rate(args)
def __set_batch_nos(self, bom_item, item_row, rm_obj, qty):
key = (rm_obj.rm_item_code, item_row.item_code, item_row.get(self.subcontract_data.order_field))
@@ -1148,7 +1148,7 @@ class SubcontractingController(StockController):
and reset_outgoing_rate
and frappe.get_cached_value("Item", item.rm_item_code, "is_stock_item")
):
rate = get_incoming_rate(
rate = _get_incoming_rate(
{
"item_code": item.rm_item_code,
"warehouse": self.supplier_warehouse,

View File

@@ -0,0 +1,95 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
class TestPriceListValidation(FrappeTestCase):
def create_price_list(self, selling=0, buying=0, enabled=1):
return (
frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": selling,
"buying": buying,
"enabled": enabled,
}
)
.insert()
.name
)
def test_selling_transaction_should_reject_a_buying_price_list(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = self.create_price_list(buying=1)
with self.assertRaisesRegex(frappe.ValidationError, "selling transaction"):
invoice.save()
def test_buying_transaction_should_reject_a_selling_price_list(self):
invoice = make_purchase_invoice(do_not_save=1)
invoice.buying_price_list = self.create_price_list(selling=1)
with self.assertRaisesRegex(frappe.ValidationError, "buying transaction"):
invoice.save()
def test_a_price_list_for_both_sides_should_be_accepted(self):
price_list = self.create_price_list(selling=1, buying=1)
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = price_list
invoice.save()
self.assertEqual(invoice.selling_price_list, price_list)
def test_a_missing_price_list_should_report_rather_than_crash(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = frappe.generate_hash(length=10)
with self.assertRaises(frappe.ValidationError):
invoice.validate_price_list()
def test_internal_transfer_should_keep_the_outward_price_list(self):
"""The inward document of an internal transfer takes the price list of the outward one, which
is flagged for the opposite side."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
selling_only = self.create_price_list(selling=1)
make_purchase_receipt(warehouse="Stores - TCP1", company=company)
delivery_note = create_delivery_note(
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
warehouse="Stores - TCP1",
target_warehouse=create_warehouse("_Test Transit For Price List", company=company),
do_not_submit=1,
)
delivery_note.selling_price_list = selling_only
delivery_note.save()
delivery_note.submit()
receipt = make_inter_company_purchase_receipt(delivery_note.name)
receipt.items[0].warehouse = "Stores - TCP1"
receipt.save()
self.assertEqual(receipt.buying_price_list, selling_only)
def test_disabled_price_list_should_still_report_as_disabled(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = self.create_price_list(selling=1, enabled=0)
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
invoice.save()

View File

@@ -517,7 +517,7 @@
"idx": 5,
"image_field": "image",
"links": [],
"modified": "2025-06-26 11:02:01.158901",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "CRM",
"name": "Lead",
@@ -574,6 +574,10 @@
"read": 1,
"report": 1,
"role": "Sales User"
},
{
"role": "Support Team",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -474,6 +474,14 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None):
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
"""raise a issue from email"""
# `communication` is caller-supplied. Communication grants read to `All` only for the owner and
# carries a has_permission hook, so doc= is what decides access.
frappe.has_permission("Communication", doc=communication, throw=True)
# the insert path checks `create` itself, but the path that reuses an existing Lead required
# nothing, so it returned a Lead name to callers with no access to Leads at all
frappe.has_permission("Lead", ptype="create", throw=True)
doc = frappe.get_doc("Communication", communication)
lead_name = None
if doc.sender:

View File

@@ -18,7 +18,7 @@
}
],
"links": [],
"modified": "2025-12-17 12:09:34.687368",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "CRM",
"name": "Market Segment",
@@ -36,6 +36,10 @@
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"role": "Sales Master Manager",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -553,8 +553,14 @@ def make_opportunity_from_communication(
):
from erpnext.crm.doctype.lead.lead import make_lead_from_communication
# `communication` is caller-supplied. Communication grants read to `All` only for the owner and
# carries a has_permission hook, so doc= is what decides access.
frappe.has_permission("Communication", doc=communication, throw=True)
doc = frappe.get_doc("Communication", communication)
# make_lead_from_communication() carries its own check, but it is skipped entirely when the
# email already references a Lead, so this cannot rely on it.
lead = doc.reference_name if doc.reference_doctype == "Lead" else None
if not lead:
lead = make_lead_from_communication(communication, ignore_communication_links=True)

View File

@@ -150,6 +150,10 @@ def link_open_events(ref_doctype, ref_docname, doc):
@frappe.whitelist()
def get_open_activities(ref_doctype, ref_docname):
# both arguments are caller-supplied and the ToDo/Event rows are read with get_all, so the
# referenced document decides who may see its activities. doc= applies User Permissions.
frappe.has_permission(ref_doctype, doc=ref_docname, throw=True)
tasks = get_open_todos(ref_doctype, ref_docname)
events = get_open_events(ref_doctype, ref_docname)

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import re
from typing import TYPE_CHECKING
import frappe
@@ -78,8 +79,48 @@ class CodeList(Document):
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)
def _version_key(version: str | None) -> list:
"""Natural sort key for the version formats publishers use: integers and ISO dates.
Orders 3 < 10 (which a lexical sort gets wrong) and 2020-01-01 < 2020-11-05.
"""
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", version or "")]
@frappe.request_cache
def resolve_code_list(code_list: str) -> str | None:
"""Return the Code List for a document name or a canonical URI.
Code Lists are named after their CanonicalVersionUri, so one canonical URI can
map to several documents, one per version. An exact document name takes
precedence, which lets a caller request a specific version; a canonical URI
resolves to the latest version available.
"""
if frappe.db.exists("Code List", code_list):
return code_list
candidates = frappe.get_all(
"Code List",
filters={"canonical_uri": code_list},
fields=["name", "version"],
)
if not candidates:
return None
# ponytail: assumes one publisher sticks to one version format. An integer and an
# ISO date under the same canonical URI compare numerically (3 < 2020), so the date
# would win; import the genericode ValidityDate and sort on that if it ever happens.
return max(candidates, key=lambda cl: _version_key(cl.version)).name
def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
"""Return the common code for a given record"""
"""Return the common code for a given record.
`code_list` may be a Code List name or a canonical URI (latest version wins).
"""
if not (code_list := resolve_code_list(code_list)):
return ()
CommonCode = frappe.qb.DocType("Common Code")
DynamicLink = frappe.qb.DocType("Dynamic Link")
@@ -101,7 +142,13 @@ def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
"""Return the record name for a given common code"""
"""Return the record name for a given common code.
`code_list` may be a Code List name or a canonical URI (latest version wins).
"""
if not (code_list := resolve_code_list(code_list)):
return ()
CommonCode = frappe.qb.DocType("Common Code")
DynamicLink = frappe.qb.DocType("Dynamic Link")
@@ -123,6 +170,12 @@ def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
def get_default_code(code_list: str) -> str | None:
"""Return the default common code for a given code list"""
"""Return the default common code for a given code list.
`code_list` may be a Code List name or a canonical URI (latest version wins).
"""
if not (code_list := resolve_code_list(code_list)):
return None
code_id = frappe.db.get_value("Code List", code_list, "default_common_code")
return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None

View File

@@ -20,8 +20,11 @@ class CodeListSelectionMismatchError(Exception):
pass
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def import_genericode():
# check before save(), which only runs after the XML is fetched and parsed; denies exactly who save() would, sooner
frappe.has_permission("Code List", "create", throw=True)
try:
content, file_name = get_uploaded_genericode_file()
@@ -161,7 +164,7 @@ def parse_genericode_content(content: bytes):
return etree.fromstring(content, parser=parser)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def process_genericode_import(
code_list_name: str,
file_name: str,
@@ -172,6 +175,11 @@ def process_genericode_import(
):
from erpnext.edi.doctype.common_code.common_code import import_genericode
# Same reasoning as above: common_code.save() enforces this per document, but only after the
# file has been read and its XML parsed and queried.
frappe.has_permission("Common Code", "create", throw=True)
frappe.has_permission("Code List", doc=code_list_name, throw=True)
column_map = {"code": code_column, "title": title_column, "description": description_column}
return import_genericode(code_list_name, file_name, column_map, json.loads(filters) if filters else None)

View File

@@ -1,9 +1,83 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.edi.doctype.code_list.code_list import (
_version_key,
get_codes_for,
get_default_code,
get_docnames_for,
resolve_code_list,
)
CANONICAL_URI = "urn:test:erpnext:codeliste:resolve"
OLD_VERSION = f"{CANONICAL_URI}:3"
NEW_VERSION = f"{CANONICAL_URI}:10"
UNKNOWN_URI = "urn:test:erpnext:codeliste:missing"
class TestCodeList(FrappeTestCase):
pass
def setUp(self):
"""Create two versions of one code list. FrappeTestCase rolls back once per class, so the inserts are guarded."""
for name, version in ((OLD_VERSION, "3"), (NEW_VERSION, "10")):
if not frappe.db.exists("Code List", name):
frappe.get_doc(
doctype="Code List",
name=name,
title=name,
canonical_uri=CANONICAL_URI,
version=version,
).insert()
default_code = frappe.get_doc(
doctype="Common Code",
title="Test Default",
common_code="XYZ",
code_list=NEW_VERSION,
).insert()
frappe.db.set_value("Code List", NEW_VERSION, "default_common_code", default_code.name)
# resolution is request-cached, so fixtures must not be masked by earlier lookups
frappe.local.request_cache.clear()
def test_version_key_orders_integers_and_iso_dates(self):
"""Integer and ISO date versions must both order correctly, unlike a lexical sort."""
self.assertEqual(sorted(["10", "3", None, "9"], key=_version_key), [None, "3", "9", "10"])
self.assertEqual(
sorted(["2020-11-05", "2019-12-31", "2020-01-01"], key=_version_key),
["2019-12-31", "2020-01-01", "2020-11-05"],
)
def test_canonical_uri_resolves_to_latest_version(self):
self.assertEqual(resolve_code_list(CANONICAL_URI), NEW_VERSION)
def test_name_resolves_to_itself(self):
"""Passing a version-specific name must return that version, not the latest one."""
self.assertEqual(resolve_code_list(OLD_VERSION), OLD_VERSION)
def test_name_takes_precedence_over_canonical_uri(self):
"""A document named like a canonical URI must not redirect to another version."""
frappe.get_doc(
doctype="Code List",
name=CANONICAL_URI,
title=CANONICAL_URI,
canonical_uri=CANONICAL_URI,
version="1",
).insert()
frappe.local.request_cache.clear()
self.assertEqual(resolve_code_list(CANONICAL_URI), CANONICAL_URI)
def test_unknown_uri_resolves_to_none(self):
self.assertIsNone(resolve_code_list(UNKNOWN_URI))
def test_lookups_are_empty_for_unknown_code_list(self):
"""An unresolved code list must not fall through to an unfiltered query."""
self.assertEqual(get_codes_for(UNKNOWN_URI, "UOM", "Nos"), ())
self.assertEqual(get_docnames_for(UNKNOWN_URI, "UOM", "XYZ"), ())
self.assertIsNone(get_default_code(UNKNOWN_URI))
def test_default_code_follows_latest_version(self):
self.assertEqual(get_default_code(CANONICAL_URI), "XYZ")

View File

@@ -37,8 +37,12 @@ class PlaidSettings(Document):
return plaid.get_link_token()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def get_plaid_configuration():
# Returns plaid_env and a freshly minted Plaid link_token. Plaid Settings is a System-Manager-only
# single doctype and every caller reaches this from its own form, so that is the boundary.
frappe.has_permission("Plaid Settings", throw=True)
if frappe.db.get_single_value("Plaid Settings", "enabled"):
plaid_settings = frappe.get_single("Plaid Settings")
return {
@@ -52,6 +56,8 @@ def get_plaid_configuration():
@frappe.whitelist()
def add_institution(token, response):
frappe.has_permission("Plaid Settings", throw=True)
response = json.loads(response)
plaid = PlaidConnector()
@@ -80,6 +86,8 @@ def add_institution(token, response):
@frappe.whitelist()
def add_bank_accounts(response, bank, company):
frappe.has_permission("Plaid Settings", throw=True)
try:
response = json.loads(response)
except TypeError:
@@ -319,8 +327,10 @@ def automatic_synchronization():
enqueue_synchronization()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def enqueue_synchronization():
frappe.has_permission("Plaid Settings", throw=True)
plaid_accounts = frappe.get_all(
"Bank Account", filters={"integration_id": ["!=", ""]}, fields=["name", "bank"]
)
@@ -333,8 +343,12 @@ def enqueue_synchronization():
)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def get_link_token_for_update(access_token):
# `access_token` is caller-supplied and is used to mint a link token at Plaid, so this creates
# state at the provider even though it writes nothing here.
frappe.has_permission("Plaid Settings", throw=True)
plaid = PlaidConnector(access_token)
return plaid.get_link_token(update_mode=True)
@@ -353,8 +367,10 @@ def get_company(bank_account_name):
frappe.throw(_("Could not detect the Company for updating Bank Accounts"))
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def update_bank_account_ids(response):
frappe.has_permission("Plaid Settings", throw=True)
data = json.loads(response)
institution_name = data["institution"]["name"]
bank = frappe.get_doc("Bank", institution_name).as_dict()

View File

@@ -147,7 +147,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-12-05 15:44:21.520093",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Blanket Order",
@@ -167,6 +167,30 @@
"share": 1,
"submit": 1,
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -641,7 +641,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2025-11-19 16:17:15.925156",
"modified": "2026-09-22 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",
@@ -672,6 +672,38 @@
"share": 1,
"submit": 1,
"write": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"read": 1,
"role": "Purchase Manager",
"select": 1
},
{
"read": 1,
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
},
{
"read": 1,
"role": "Stock Manager",
"select": 1
},
{
"read": 1,
"role": "Stock User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -15,7 +15,7 @@ from frappe.website.website_generator import WebsiteGenerator
import erpnext
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.item.item import get_item_details
from erpnext.stock.doctype.item.item import _get_item_details
from erpnext.stock.get_item_details import get_conversion_factor, get_price_list_rate
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -337,7 +337,7 @@ class BOM(WebsiteGenerator):
self.manage_default_bom()
def get_item_det(self, item_code):
item = get_item_details(item_code)
item = _get_item_details(item_code)
if not item:
frappe.throw(_("Item: {0} does not exist in the system").format(item_code))
@@ -1164,7 +1164,11 @@ def get_bom_items_as_dict(
fetch_scrap_items=0,
include_non_stock_items=False,
fetch_qty_in_stock_uom=True,
ignore_permissions=True,
):
if not ignore_permissions:
frappe.has_permission("BOM", "read", doc=bom, throw=True)
item_dict = {}
# Did not use qty_consumed_per_unit in the query, as it leads to rounding loss
@@ -1256,7 +1260,11 @@ def get_bom_items_as_dict(
@frappe.whitelist()
def get_bom_items(bom, company, qty=1, fetch_exploded=1):
items = get_bom_items_as_dict(bom, company, qty, fetch_exploded, include_non_stock_items=True).values()
frappe.has_permission("BOM", "read", doc=bom, throw=True)
items = get_bom_items_as_dict(
bom, company, qty, fetch_exploded, include_non_stock_items=True, ignore_permissions=False
).values()
items = list(items)
items.sort(key=functools.cmp_to_key(lambda a, b: a.item_code > b.item_code and 1 or -1))
return items
@@ -1530,6 +1538,8 @@ def get_bom_diff(bom1, bom2):
doc1 = frappe.get_doc("BOM", bom1)
doc2 = frappe.get_doc("BOM", bom2)
doc1.check_permission()
doc2.check_permission()
out = get_diff(doc1, doc2)
out.row_changed = []
@@ -1634,7 +1644,7 @@ def make_variant_bom(source_name, bom_no, item, variant_items, target_doc=None):
doc.item = item
doc.quantity = 1
item_data = get_item_details(item)
item_data = _get_item_details(item)
doc.update(
{
"item_name": item_data.item_name,

View File

@@ -355,6 +355,10 @@ frappe.ui.form.on("Job Card", {
default: frm.doc.for_quantity - frm.doc.total_completed_qty,
},
(data) => {
if (data.qty < 0) {
frappe.throw(__("Completed Quantity cannot be negative"));
}
frm.events.complete_job(frm, "Complete", data.qty);
},
__("Enter Value")

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