Compare commits

..

217 Commits

Author SHA1 Message Date
Frappe PR Bot
9d5c7605b8 chore(release): Bumped to Version 16.27.0
# [16.27.0](https://github.com/frappe/erpnext/compare/v16.26.2...v16.27.0) (2026-07-13)

### Bug Fixes

* **`frappe_crm_api`:** handle failure for `after_app_install` and `after_app_uninstall` ([eac3afc](eac3afcd88))
* **accounts:** clear clearance date when amending reconciled voucher ([6f28135](6f281356a6)), closes [#54909](https://github.com/frappe/erpnext/issues/54909)
* **accounts:** retain invoice table on opening invoice creation error (backport [#56353](https://github.com/frappe/erpnext/issues/56353)) ([#57058](https://github.com/frappe/erpnext/issues/57058)) ([4136ca5](4136ca55a7))
* add page numbers to print format footer ([0371e8e](0371e8eaf0))
* added permission checks on various whitelisted functions (backport [#56745](https://github.com/frappe/erpnext/issues/56745)) ([#56946](https://github.com/frappe/erpnext/issues/56946)) ([7ce1289](7ce1289c10))
* address review comments ([056195c](056195ce07))
* auto fetch serial no from previous operation output (backport to v16) ([#56861](https://github.com/frappe/erpnext/issues/56861)) ([17f2de4](17f2de42f3))
* **banking:** allow negative balance in bank statement import (backport [#56959](https://github.com/frappe/erpnext/issues/56959)) ([#57054](https://github.com/frappe/erpnext/issues/57054)) ([835e32c](835e32cec7))
* **banking:** handle blank password protected PDFs and negative amounts in CR/DR columns (backport [#56690](https://github.com/frappe/erpnext/issues/56690)) ([#56694](https://github.com/frappe/erpnext/issues/56694)) ([19c318d](19c318df68))
* **banking:** use custom renderer for translated strings and parser for rules (backport [#56643](https://github.com/frappe/erpnext/issues/56643)) ([#56647](https://github.com/frappe/erpnext/issues/56647)) ([2cb577b](2cb577b912))
* block serialized to non-serialized item change when SABB exists (backport [#56773](https://github.com/frappe/erpnext/issues/56773)) ([#56775](https://github.com/frappe/erpnext/issues/56775)) ([19d03fe](19d03fee46))
* clear stray permission message when item dashboard has no warehouse access ([a39753e](a39753ee08))
* **company:** ignore user permissions for link fields having link to `Account` and `Cost Center` (backport [#56748](https://github.com/frappe/erpnext/issues/56748)) ([#56795](https://github.com/frappe/erpnext/issues/56795)) ([003b655](003b6554c4))
* correct filter handling in Sales Person-wise Transaction Summary + tests (backport [#56783](https://github.com/frappe/erpnext/issues/56783)) ([#56908](https://github.com/frappe/erpnext/issues/56908)) ([24f0989](24f0989ac1))
* **crm_settings:** skip allowed users check when frappe crm is installed locally ([dfe4d5a](dfe4d5ab73))
* display outstanding amount using company default currency (backport [#56785](https://github.com/frappe/erpnext/issues/56785)) ([#57009](https://github.com/frappe/erpnext/issues/57009)) ([bdba4c8](bdba4c8091))
* don't attempt to create SABB for non-serialized / non-batch items (backport [#56627](https://github.com/frappe/erpnext/issues/56627)) ([#56803](https://github.com/frappe/erpnext/issues/56803)) ([8c56a5a](8c56a5ac0c))
* don't treat batch slot at FIFO queue head as qty slot ([20f6dd0](20f6dd0224))
* enforce read permission on ledger preview endpoints ([#57039](https://github.com/frappe/erpnext/issues/57039)) ([5642359](5642359c10))
* fetch payment entry reference amounts from invoice (backport [#56928](https://github.com/frappe/erpnext/issues/56928)) ([#57042](https://github.com/frappe/erpnext/issues/57042)) ([67c85ef](67c85ef0af))
* FIFO queue checks and incorrect entries filter in stock ledger reports ([3571512](3571512101)), closes [#43619](https://github.com/frappe/erpnext/issues/43619)
* **financial_statement:** render columnar financial statements instea… (backport [#56921](https://github.com/frappe/erpnext/issues/56921)) ([#57053](https://github.com/frappe/erpnext/issues/57053)) ([e6a6c13](e6a6c13355))
* for purchases do voucher based reposting (backport [#56601](https://github.com/frappe/erpnext/issues/56601)) ([#56608](https://github.com/frappe/erpnext/issues/56608)) ([e1e6176](e1e6176ddc))
* guard against missing DocType in onboarding steps patch ([#56804](https://github.com/frappe/erpnext/issues/56804)) ([f72289e](f72289e27c))
* **item-attribute:** clear attribute values when marking numeric ([374b340](374b340e73))
* **item:** error on uncommitted input and escape values in variant dialog ([04c834d](04c834d6a9))
* **item:** rework multiple variant dialog for large numeric ranges ([025d0cd](025d0cd7f3))
* make represents company field in purchase invoice ignore user permissions ([4aed77d](4aed77df0c))
* make trend report based-on and group-by column labels translatable ([b8199d8](b8199d88b6))
* map stock_qty in apply_price_list_on_item (backport [#56869](https://github.com/frappe/erpnext/issues/56869)) ([#57052](https://github.com/frappe/erpnext/issues/57052)) ([240fb2c](240fb2c4b8))
* partial delivery note against pick list (backport [#56985](https://github.com/frappe/erpnext/issues/56985)) ([#57006](https://github.com/frappe/erpnext/issues/57006)) ([20255a8](20255a8a7f))
* **patch:** moved create_company_custom_fields from `pre_model_sync` to `post_model_sync` (backport [#56962](https://github.com/frappe/erpnext/issues/56962)) ([#56965](https://github.com/frappe/erpnext/issues/56965)) ([14efd14](14efd14384))
* **payment reconciliation:** honour user permissions on accounting di… ([#56560](https://github.com/frappe/erpnext/issues/56560)) ([8eb92b8](8eb92b8b18))
* precision issue causing reconciliation error ([#54043](https://github.com/frappe/erpnext/issues/54043)) ([20df15b](20df15b3ac))
* prevent repeatable read related concurrency errors ([019b02a](019b02adcd))
* race condition and repeatable read in process pcv ([6d9f5fa](6d9f5fac76))
* recompute transferred qty before deciding work order status ([bc952f4](bc952f40d9))
* remove company default on cost center in stock entry detail ([a1fe6ce](a1fe6cee5d))
* remove incorrect Payable account_type from Customer Deposits in Philippines CoA (backport [#57018](https://github.com/frappe/erpnext/issues/57018)) ([#57061](https://github.com/frappe/erpnext/issues/57061)) ([edfbc71](edfbc71135))
* rename variant item_code/item_name when attribute abbreviation changes ([97cd1e7](97cd1e7144))
* render letter head footer in print formats ([fa4d32d](fa4d32dcdb))
* replay immutable SLE qty for serial/batch bundle valuation (backport [#56814](https://github.com/frappe/erpnext/issues/56814)) ([#56835](https://github.com/frappe/erpnext/issues/56835)) ([2264e25](2264e25cc6))
* resolve conflicts ([3aad7fe](3aad7fee24))
* restore Save button on reverse journal entry (backport [#56770](https://github.com/frappe/erpnext/issues/56770)) ([#56772](https://github.com/frappe/erpnext/issues/56772)) ([4573cd1](4573cd15a9))
* **selling:** update sales order per billed on credit note submission ([224cf19](224cf19f33))
* set conversion_rate on quotation created from customer ([26905bc](26905bc142))
* show only template items in Variant Of filter ([bebe011](bebe011636))
* skip item prices tab render for users without item price read access ([c964811](c964811293))
* skip stock reservation for opted-out production plans ([17598e2](17598e2626)), closes [#56798](https://github.com/frappe/erpnext/issues/56798)
* **stock:** backfill transferred qty for existing pick lists ([903d78c](903d78cc43))
* **stock:** link job card in stock entry created from pick list (backport [#57031](https://github.com/frappe/erpnext/issues/57031)) ([755b9cc](755b9ccbc3))
* **stock:** pick list serial batch posting date ([#57015](https://github.com/frappe/erpnext/issues/57015)) ([7e46be2](7e46be2a33)), closes [#56951](https://github.com/frappe/erpnext/issues/56951)
* **stock:** propagate project from job card to stock entry ([51d2077](51d2077eed))
* **stock:** support quality inspection for stock entry by purpose (backport [#56446](https://github.com/frappe/erpnext/issues/56446)) ([40ca3b5](40ca3b5e5d))
* update BOM operations when routing is changed ([b2e6a39](b2e6a39743))
* update events order by date asc (backport [#56963](https://github.com/frappe/erpnext/issues/56963)) ([#57056](https://github.com/frappe/erpnext/issues/57056)) ([e2fd061](e2fd061b3d))
* update modified timestamp in item.json ([f602ee0](f602ee0e73))
* use live source warehouse valuation for internal transfer purchase receipts (backport [#56431](https://github.com/frappe/erpnext/issues/56431)) ([#56677](https://github.com/frappe/erpnext/issues/56677)) ([1da28f2](1da28f2278))
* validate planned end date is not before planned start date in work order ([745baad](745baad0d1))
* validate reverse GL entries on current date under immutable ledger (backport [#56709](https://github.com/frappe/erpnext/issues/56709)) ([#56752](https://github.com/frappe/erpnext/issues/56752)) ([eea4756](eea4756ce9))
* validate template and its variant in the same Pricing Rule ([159a253](159a2538da))

### Features

* capture user persona during setup (backport [#56705](https://github.com/frappe/erpnext/issues/56705)) ([#56707](https://github.com/frappe/erpnext/issues/56707)) ([d9aa449](d9aa4491f1))
* **crm_settings:** auto-update crm sync settings on frappe crm install and uninstall ([cb2a930](cb2a930a32))
* explain FIFO allocation of fixed Discount Amount on Sales Order ([#56436](https://github.com/frappe/erpnext/issues/56436)) ([62fed1d](62fed1d562))
* **manufacturing:** create material request for raw materials from work order ([#56980](https://github.com/frappe/erpnext/issues/56980)) ([144f92d](144f92d58e))
* **sla:** filter service level agreement link by document type (backport [#56954](https://github.com/frappe/erpnext/issues/56954)) ([#56956](https://github.com/frappe/erpnext/issues/56956)) ([393b5d1](393b5d1f74))
* **stock:** support partial transfer from pick list ([af495ed](af495ed253))

### Performance Improvements

* avoid per-row Warehouse doc fetches in auto reorder job ([39b5e12](39b5e12305))
* batch bin lookups in delivery note stock update ([697e2c0](697e2c0b66))
2026-07-13 07:41:31 +00:00
Diptanil Saha
f94eee3197 Merge pull request #56931 from frappe/version-16-hotfix
chore: release v16
2026-07-13 12:09:52 +05:30
Mihir Kandoi
90768c42d8 Merge pull request #57075 from frappe/mergify/bp/version-16-hotfix/pr-57073 2026-07-13 10:46:36 +05:30
ruthra kumar
fd3bebee02 Merge pull request #57023 from frappe/mergify/bp/version-16-hotfix/pr-56801
refactor: reversing exchange rate revaluation journals (backport #56801)
2026-07-13 10:13:57 +05:30
ruthra kumar
431ae6bc93 chore: resolve conflict 2026-07-13 10:02:18 +05:30
Mihir Kandoi
4aed77df0c fix: make represents company field in purchase invoice ignore user permissions
(cherry picked from commit 6729a53fee)
2026-07-12 18:39:28 +00:00
MochaMind
9e95bfe472 chore: update POT file (#57066) 2026-07-12 20:25:28 +02:00
Mihir Kandoi
b6821c0ee5 Merge pull request #57044 from mihir-kandoi/fix-job-card-pick-list-transfer-v16
fix(stock): link job card in stock entry created from pick list (backport #57031)
2026-07-12 17:51:50 +05:30
Mihir Kandoi
51d2077eed fix(stock): propagate project from job card to stock entry 2026-07-12 17:31:22 +05:30
rohitwaghchaure
5642359c10 fix: enforce read permission on ledger preview endpoints (#57039)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-07-12 09:53:17 +00:00
mergify[bot]
4136ca55a7 fix(accounts): retain invoice table on opening invoice creation error (backport #56353) (#57058)
* fix(accounts): retain invoice table on opening invoice creation error (#56353)

Co-authored-by: diptanilsaha <diptanil@frappe.io>
(cherry picked from commit ad17efe243)

# Conflicts:
#	erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py

* chore: resolve conflicts

---------

Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-12 07:45:18 +00:00
MochaMind
2e892be1c1 chore: sync translations to version-16-hotfix (#56911) 2026-07-12 13:12:57 +05:30
mergify[bot]
edfbc71135 fix: remove incorrect Payable account_type from Customer Deposits in Philippines CoA (backport #57018) (#57061)
Co-authored-by: Raghav Ruia <168326921+raghavisruia@users.noreply.github.com>
2026-07-11 17:52:48 +00:00
Mohammad Umair Sayed
62fed1d562 feat: explain FIFO allocation of fixed Discount Amount on Sales Order (#56436)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-11 17:41:20 +00:00
mergify[bot]
e2fd061b3d fix: update events order by date asc (backport #56963) (#57056)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
2026-07-11 18:45:35 +05:30
mergify[bot]
835e32cec7 fix(banking): allow negative balance in bank statement import (backport #56959) (#57054)
fix(banking): allow negative balance in bank statement import (#56959)

(cherry picked from commit d449ad3b3f)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-07-11 18:18:17 +05:30
Diptanil Saha
04d982fa0d Merge branch 'version-16' into version-16-hotfix 2026-07-11 18:17:59 +05:30
mergify[bot]
e6a6c13355 fix(financial_statement): render columnar financial statements instea… (backport #56921) (#57053)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-07-11 10:41:49 +00:00
mergify[bot]
24f0989ac1 fix: correct filter handling in Sales Person-wise Transaction Summary + tests (backport #56783) (#56908)
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
2026-07-11 15:28:23 +05:30
mergify[bot]
240fb2c4b8 fix: map stock_qty in apply_price_list_on_item (backport #56869) (#57052)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-07-11 15:26:36 +05:30
Vishnu Priya Baskaran
8eb92b8b18 fix(payment reconciliation): honour user permissions on accounting di… (#56560) 2026-07-11 15:04:44 +05:30
Mihir Kandoi
755b9ccbc3 fix(stock): link job card in stock entry created from pick list (backport #57031)
A Stock Entry created from a Pick List against a job card's Material
Request never set job_card, job_card_item, fg_completed_qty or the
'Material Transfer for Manufacture' purpose, so the Job Card did not
recognize the transfer and blocked submission. The WIP warehouse was
also not populated.

Route such pick lists through a job-card-aware branch mirroring the
direct Material Request -> Stock Entry mapper, and set the purpose to
'Material Transfer for Manufacture' in the work order branch so the
WO -> MR -> Pick List flow updates the work order too.
2026-07-10 17:46:57 +05:30
mergify[bot]
67c85ef0af fix: fetch payment entry reference amounts from invoice (backport #56928) (#57042)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-07-10 11:43:01 +00:00
mergify[bot]
bdba4c8091 fix: display outstanding amount using company default currency (backport #56785) (#57009)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: S Sakthivel Murugan <s.sakthivelmurugan2003@gmail.com>
2026-07-10 15:36:34 +05:30
Mihir Kandoi
c56eb2635a Merge pull request #57024 from aerele/backport-56923-version-16-hotfix
perf: avoid per-row Warehouse doc fetches in auto reorder job
2026-07-10 12:43:56 +05:30
Mihir Kandoi
f96166af5b Merge pull request #57028 from aerele/backport-56913-version-16-hotfix
fix: make trend report based-on and group-by column labels translatable
2026-07-10 12:43:47 +05:30
pandiyan
b8199d88b6 fix: make trend report based-on and group-by column labels translatable
based_wise_columns_query() and group_wise_column() built column labels as
raw strings, so headers like Item, Item Name, Customer, Supplier, and
Territory never went through the _() translation function and stayed in
English regardless of the user's language, while period and total columns
translated fine. Build these as column dicts with an explicit _()-wrapped
label instead, so they're translated the same way as the rest of the report.

(cherry picked from commit 015fa68fc0)
2026-07-10 12:16:45 +05:30
mergify[bot]
20255a8a7f fix: partial delivery note against pick list (backport #56985) (#57006)
* fix: partial delivery note against pick list (#56985)

(cherry picked from commit 53af4d53ef)

# Conflicts:
#	erpnext/stock/doctype/pick_list/test_pick_list.py

* chore: fix conflicts

Refactor tests for pick list to improve clarity and organization.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-10 06:38:02 +00:00
ruthra kumar
4711a28dd0 refactor(test): for reverse journals as well
(cherry picked from commit 65775e59a1)

# Conflicts:
#	erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py
2026-07-10 06:20:54 +00:00
ruthra kumar
63e5117182 refactor: handle reverse ERR journals in AR / AP report
(cherry picked from commit 6838242063)
2026-07-10 06:20:53 +00:00
ruthra kumar
051757760f refactor: reversal capability on exchange rate revaluation
(cherry picked from commit a0b14c0607)

# Conflicts:
#	erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
2026-07-10 06:20:53 +00:00
ruthra kumar
277c651a9f refactor: add payment ledger to ignore link
(cherry picked from commit 6a4c5b6062)
2026-07-10 06:20:53 +00:00
pandiyan
39b5e12305 perf: avoid per-row Warehouse doc fetches in auto reorder job
get_item_warehouse_projected_qty() called frappe.get_doc("Warehouse", ...)
inside the per-bin loop to walk up the warehouse hierarchy, re-fetching the
same parent warehouses over and over on sites with nested warehouses. Preload
the warehouse-to-parent mapping with a single query and walk it in-memory
instead, cutting the DB round-trips from O(bins * hierarchy depth) to one
query.

(cherry picked from commit 6beb3d2509)
2026-07-10 11:48:04 +05:30
mergify[bot]
e1e6176ddc fix: for purchases do voucher based reposting (backport #56601) (#56608)
* fix: for purchases do voucher based reposting (#56601)

(cherry picked from commit 5523c15ab8)

* chore: fix type hints

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-10 06:13:01 +00:00
Pandiyan P
7e46be2a33 fix(stock): pick list serial batch posting date (#57015)
* fix(stock): fall back to current date/time for serial and batch bundle posting datetime

Pick List has no posting_date/posting_time fields, so creating or updating a
Serial and Batch Bundle from a Pick List row crashed with
"TypeError: combine() argument 1 must be datetime.date, not None". Fall back
to today/now when the parent voucher doesn't carry its own posting date.

Fixes #56951

* fix(stock): accept a plain dict for add_serial_batch_ledgers' doc and child_row

The whitelisted add_serial_batch_ledgers only converted child_row into an
attribute-accessible frappe._dict when it arrived as a JSON string, and doc's
type hint only allowed Document | str. Frappe's JSON API delivers both as
plain dicts (see frappe.app.make_form_dict, which parses the request body
with orjson and only wraps the top-level dict, not nested values), so every
real request was rejected before the handler body ever ran: first with a
FrappeTypeError on doc, and once that's fixed, with an AttributeError on
child_row.serial_and_batch_bundle. parse_json already wraps a plain dict in
frappe._dict (and leaves a real Document instance untouched), so routing
child_row through it unconditionally fixes both.
2026-07-10 11:37:50 +05:30
mergify[bot]
2264e25cc6 fix: replay immutable SLE qty for serial/batch bundle valuation (backport #56814) (#56835)
fix: replay immutable SLE qty for serial/batch bundle valuation (#56814)

(cherry picked from commit ecc8ec672b)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-10 04:24:03 +00:00
Mihir Kandoi
7f64dfbccc Merge pull request #56988 from frappe/mergify/bp/version-16-hotfix/pr-56909
fix(stock): rename variant item_code/item_name when attribute abbreviation changes (backport #56909)
2026-07-09 21:23:33 +05:30
Mihir Kandoi
d4e16b0228 Merge pull request #56994 from frappe/mergify/bp/version-16-hotfix/pr-56925
perf: batch bin lookups in delivery note stock update (backport #56925)
2026-07-09 21:23:20 +05:30
Mihir Kandoi
3bf8dd7297 Merge pull request #56996 from frappe/mergify/bp/version-16-hotfix/pr-56932
fix: validate planned end date is not before planned start date in wo… (backport #56932)
2026-07-09 21:23:11 +05:30
Mihir Kandoi
50a40a7819 Merge pull request #56836 from aerele/fix/wo-status-partial-pick-v16
fix(manufacturing): update work order status on partial pick-list transfer
2026-07-09 21:22:49 +05:30
Sudharsanan11
dd264506db test(manufacturing): add test to validate the work order status on partial pick-list transfer
Cover the pick-list flow where a stock entry moves only one of the work
order's required items: material_transferred_for_manufacturing stays 0 (min
fraction) while the status must move to "in process".
2026-07-09 21:07:12 +05:30
Mihir Kandoi
2e535042b4 Merge pull request #57000 from frappe/mergify/bp/version-16-hotfix/pr-56255
Fix Variant Of filter to show only template items (backport #56255)
2026-07-09 20:57:40 +05:30
Mihir Kandoi
7dd7d7939f Merge pull request #56998 from frappe/mergify/bp/version-16-hotfix/pr-56979
fix: update BOM operations when routing is changed (backport #56979)
2026-07-09 20:52:54 +05:30
Mohsin Akhtar
f602ee0e73 fix: update modified timestamp in item.json
(cherry picked from commit 54da9fc27a)
2026-07-09 15:13:13 +00:00
Mohsin Akhtar
bebe011636 fix: show only template items in Variant Of filter
(cherry picked from commit 2433129850)
2026-07-09 15:13:13 +00:00
Mohd Haris
b2e6a39743 fix: update BOM operations when routing is changed
The routing field handler only fetched operations from the routing when
the operations table was empty. When a new BOM version is created (via
"New Version"), operations are copied from the source BOM, so selecting a
different routing left the old operations in place - both in the form and
after saving.

Drop the `!frm.doc.operations.length` guard from the routing handler so
that (re)selecting a routing always refetches the operations from that
routing via the existing get_routing method, which clears and repopulates
the operations table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 758a837de4)
2026-07-09 15:12:09 +00:00
pandiyan
745baad0d1 fix: validate planned end date is not before planned start date in work order
(cherry picked from commit 2ec780cb35)
2026-07-09 15:08:02 +00:00
pandiyan
697e2c0b66 perf: batch bin lookups in delivery note stock update
update_current_stock() in delivery_note.py used to call
frappe.db.get_value("Bin", ...) separately for every row in items and
every row in packed_items - so a delivery note with 200 items and 200
packed items made 400 separate database calls on every save.

now it groups item codes by warehouse and fetches bin data with one
query per distinct warehouse, then assigns actual_qty/projected_qty to
each row from that result - same values as before, far fewer database
calls, and no cross-product over-fetch across warehouses.

(cherry picked from commit 5da878d25f)
2026-07-09 15:07:00 +00:00
pandiyan
719439f694 test: cover variant item_code/item_name rename on abbreviation change
Add regression coverage for the new abbreviation-rename propagation:
a simple item_code rename, item_name derived from a template whose
item_name differs from its item_code, and a manually customized
item_name getting rebuilt rather than left stale.

(cherry picked from commit e718a70b26)
2026-07-09 15:04:04 +00:00
pandiyan
97cd1e7144 fix: rename variant item_code/item_name when attribute abbreviation changes
Item Attribute abbreviations only got baked into a variant's item_code
and item_name at creation time (make_variant_item_code returns early
once item_code is set). Renaming an abbreviation afterwards left every
existing variant stuck with the stale code, silently out of sync with
its own attribute.

Detect abbreviation renames on Item Attribute save, find every variant
using the affected value, and rebuild+rename its item_code via
frappe.rename_doc so linked records follow along. item_name is rebuilt
in lockstep from the template's item_name, even if it had since been
customized, since both fields are meant to be derived from the same
abbreviation.

(cherry picked from commit c0cfe5f363)
2026-07-09 15:04:03 +00:00
Mihir Kandoi
8e133fcf03 Merge pull request #56915 from aerele/backport/pick-list-partial-transfer-v16
feat(stock): support partial transfer from pick list
2026-07-09 20:30:56 +05:30
Pandiyan P
144f92d58e feat(manufacturing): create material request for raw materials from work order (#56980)
* feat(manufacturing): create material request for raw materials from work order

* test(manufacturing): cover work order material request flow
2026-07-09 19:43:01 +05:30
Sudharsanan11
903d78cc43 fix(stock): backfill transferred qty for existing pick lists
Pick Lists transferred before this feature have transferred_qty = 0 and
their Stock Entry rows carry no pick_list_item link, so the new
is_fully_transferred check would never fire and, with the old
duplicate-entry guard removed, they could be transferred again. Set
transferred_qty = picked_qty for non-Delivery submitted pick lists that
already have a linked Stock Entry so they stay completed and locked.
2026-07-09 19:20:50 +05:30
Sudharsanan11
6ecbe6fd4b test(stock): add test for partial transfer status from pick list 2026-07-09 19:20:19 +05:30
Sudharsanan11
af495ed253 feat(stock): support partial transfer from pick list
Creating a Stock Entry from a Pick List blocked any further entry
(stock_entry_exists) and flipped the pick list to Completed as soon as
one entry existed, so picked stock could not be transferred in parts.

Track transferred_qty per Pick List Item (summed from submitted Stock
Entry rows via a new pick_list_item link, mirroring delivered_qty), add
a Partially Transferred status, and map each new Stock Entry from the
remaining qty so transfers can continue until fully transferred.
2026-07-09 19:20:19 +05:30
Diptanil Saha
6707d8e0ad Merge pull request #56969 from frappe/mergify/bp/version-16-hotfix/pr-56674
fix(crm_settings): skip allowed users check when frappe crm is installed locally (backport #56674)
2026-07-09 15:52:21 +05:30
diptanilsaha
eb76872da9 chore: patch to clear out allowed users on crm_settings if frappe crm is installed on the site
(cherry picked from commit 0f987d7135)
2026-07-09 10:02:15 +00:00
diptanilsaha
eac3afcd88 fix(frappe_crm_api): handle failure for after_app_install and after_app_uninstall
(cherry picked from commit 2de423e225)
2026-07-09 10:02:14 +00:00
diptanilsaha
cb2a930a32 feat(crm_settings): auto-update crm sync settings on frappe crm install and uninstall
(cherry picked from commit c86aa2d6fe)
2026-07-09 10:02:14 +00:00
diptanilsaha
dfe4d5ab73 fix(crm_settings): skip allowed users check when frappe crm is installed locally
(cherry picked from commit 41badb3d74)
2026-07-09 10:02:14 +00:00
mergify[bot]
14efd14384 fix(patch): moved create_company_custom_fields from pre_model_sync to post_model_sync (backport #56962) (#56965)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-09 09:32:48 +00:00
Shllokkk
7301be3e4c Merge pull request #56940 from frappe/mergify/bp/version-16-hotfix/pr-56926
fix: validate template and its variant in the same Pricing Rule (backport #56926)
2026-07-09 01:04:17 +05:30
mergify[bot]
393b5d1f74 feat(sla): filter service level agreement link by document type (backport #56954) (#56956)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-08 17:29:33 +02:00
mergify[bot]
7ce1289c10 fix: added permission checks on various whitelisted functions (backport #56745) (#56946)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-08 18:10:27 +05:30
ruthra kumar
b32bfab66f Merge pull request #56938 from frappe/mergify/bp/version-16-hotfix/pr-54043
fix: precision issue causing reconciliation error (backport #54043)
2026-07-07 21:42:23 +05:30
Shllokkk
159a2538da fix: validate template and its variant in the same Pricing Rule
(cherry picked from commit a88048b378)
2026-07-07 14:30:22 +00:00
Dany Robert
20df15b3ac fix: precision issue causing reconciliation error (#54043)
* fix: precision issue causing reconciliation error

* chore: code styling changes

* test: precision causing reconciliation failure

* fix: enhance payment reconciliation tests for floating-point precision

* fix(test): incorrect assertion on status

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
(cherry picked from commit be10c8ced9)
2026-07-07 14:29:21 +00:00
MochaMind
5c6631f6af chore: update POT file (#56900) 2026-07-05 20:30:21 +02:00
ruthra kumar
d27bc77644 Merge pull request #56876 from frappe/mergify/bp/version-16-hotfix/pr-56852
fix: race condition in process pcv (backport #56852)
2026-07-04 16:32:51 +05:30
ruthra kumar
5a9d40ce04 chore: linter fix
(cherry picked from commit a9ffdac806)
2026-07-04 10:53:20 +00:00
ruthra kumar
9cf7f441fb refactor(test): row name based utility methods
(cherry picked from commit dbc409736a)
2026-07-04 10:53:19 +00:00
ruthra kumar
d759574f9a refactor: prevent whole table scan while scheduling next date
- helps in concurrency isolation

(cherry picked from commit 21f4603144)
2026-07-04 10:53:19 +00:00
ruthra kumar
019b02adcd fix: prevent repeatable read related concurrency errors
Process Period Closing Voucher and Process Period Closing Voucher
Details are trackers how the jobs are processed. Keep transactions on
them very short.

(cherry picked from commit 7e4045e828)
2026-07-04 10:53:19 +00:00
ruthra kumar
6d9f5fac76 fix: race condition and repeatable read in process pcv
- Update using child table name to avoid scanning whole table, which
eventually leads to mariadb 1020 (REPEATABLE READ).
 - Avoid race condition in final summarization

(cherry picked from commit ff6881764b)
2026-07-04 10:53:19 +00:00
Mihir Kandoi
8277d62025 Merge pull request #56870 from frappe/mergify/bp/version-16-hotfix/pr-56859
Fix(stock): item form permission errors (backport #56859)
2026-07-04 14:18:51 +05:30
pandiyan
c964811293 fix: skip item prices tab render for users without item price read access
(cherry picked from commit ef794f390c)
2026-07-04 08:38:42 +00:00
pandiyan
a39753ee08 fix: clear stray permission message when item dashboard has no warehouse access
(cherry picked from commit 8c7b2f4d3c)
2026-07-04 08:38:42 +00:00
Pandiyan P
3c1f163e3b Merge pull request #56863 from frappe/mergify/bp/version-16-hotfix/pr-56837
fix: remove company default on cost center in stock entry detail  (backport #56837)
2026-07-04 12:57:19 +05:30
Pandiyan P
3aad7fee24 fix: resolve conflicts 2026-07-04 12:46:32 +05:30
rohitwaghchaure
17f2de42f3 fix: auto fetch serial no from previous operation output (backport to v16) (#56861) 2026-07-03 17:32:05 +00:00
mergify[bot]
1da28f2278 fix: use live source warehouse valuation for internal transfer purchase receipts (backport #56431) (#56677)
fix: use live source warehouse valuation for internal transfer purchase receipts (#56431)

fix: anchor incoming SLE rate to DN rate for intra-company PR transfers
(cherry picked from commit 35de9deb0a)

Co-authored-by: Shllokkk <140623894+Shllokkk@users.noreply.github.com>
2026-07-03 22:48:35 +05:30
pandiyan
149af49d01 test: cover cost center fallback to item group default in manufacture entry
the existing test_cost_center_for_manufacture only checks a raw material
row against an item-level override, which is set independently of the
":company" default guard and never exercised the bug.

(cherry picked from commit a168bb7ea4)
2026-07-03 15:43:14 +00:00
pandiyan
a1fe6cee5d fix: remove company default on cost center in stock entry detail
the ":company" default pre-filled every row before set_default_cost_center()
ran, so its "if not row.cost_center" guard was always false and the
project/item group/brand priority chain in get_default_cost_center()
never ran.

(cherry picked from commit edfa0a7a1d)

# Conflicts:
#	erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
2026-07-03 15:43:14 +00:00
Frappe PR Bot
d1d3b241ae chore(release): Bumped to Version 16.26.2
## [16.26.2](https://github.com/frappe/erpnext/compare/v16.26.1...v16.26.2) (2026-07-03)

### Bug Fixes

* **item-attribute:** clear attribute values when marking numeric ([1b1ea7f](1b1ea7f2aa))
* **item:** error on uncommitted input and escape values in variant dialog ([56d065b](56d065b919))
* **item:** rework multiple variant dialog for large numeric ranges ([6691217](66912173bd))
2026-07-03 10:29:44 +00:00
Mihir Kandoi
9cdaa738f1 Merge pull request #56848 from frappe/mergify/bp/version-16/pr-56742
fix(item): rework multiple variant dialog for large numeric ranges (backport #56741) (backport #56742)
2026-07-03 15:58:17 +05:30
Mihir Kandoi
56d065b919 fix(item): error on uncommitted input and escape values in variant dialog
Address review feedback:
- A typed-but-not-selected value passed validation yet was dropped by
  get_selected_attributes (reads committed pills only). Treat any pending
  input as an error so it is never silently omitted from creation.
- Escape pill / pending values before interpolating them into the HTML
  error message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit d4da9a3d7d)
(cherry picked from commit 04c834d6a9)
2026-07-03 10:25:48 +00:00
Mihir Kandoi
66912173bd fix(item): rework multiple variant dialog for large numeric ranges
The 'Create Multiple Variants' dialog rendered one checkbox per attribute
value and read the numeric config from the variant attribute child row. This
broke in several ways:

- A template whose attribute was made numeric after being added kept
  numeric_values=0 on the child row, so the dialog treated it as non-numeric,
  queried the empty Item Attribute Value table, and showed no values.
- Enumerating a large range (e.g. 1-100000) into checkboxes froze the browser.

Rework the dialog:

- Read numeric_values / from_range / to_range / increment from the Item
  Attribute master, and guard increment > 0.
- Replace the checkbox-per-value list with one MultiSelectPills per attribute,
  with a search placeholder.
- Stop enumerating numeric ranges: preview the first few values and validate
  typed input against the range on demand, so huge ranges stay instant.
- Block variant creation with a modal error if any selected value or pending
  input is invalid (out of range, off-increment, or not a number), so garbage
  like '00A' can't reach creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 99152b8300)
(cherry picked from commit 025d0cd7f3)
2026-07-03 10:25:48 +00:00
Mihir Kandoi
1b1ea7f2aa fix(item-attribute): clear attribute values when marking numeric
Marking an attribute numeric hides the Item Attribute Values grid but leaves
its rows in the doc, whose mandatory Attribute Value / Abbreviation block the
save client-side before the server can clear them. Clear the table on the
client too so the save goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 4afbd4d3d9)
(cherry picked from commit 374b340e73)
2026-07-03 10:25:47 +00:00
Mihir Kandoi
d20b18a209 Merge pull request #56825 from aerele/backport-56806
fix: skip stock reservation for opted-out production plans
2026-07-03 13:56:00 +05:30
Mihir Kandoi
f6916c6f2d Merge pull request #56839 from frappe/mergify/bp/version-16-hotfix/pr-56826
fix: FIFO queue checks and incorrect entries filter in stock ledger reports (backport #56826)
2026-07-03 13:51:41 +05:30
Mihir Kandoi
feb58caf1e test: drop redundant cleanup, db rolls back after each test
(cherry picked from commit 3b1e57966e)
2026-07-03 07:50:22 +00:00
Mihir Kandoi
056195ce07 fix: address review comments
- restore mutated SLE after test via addCleanup
- explicit return False in has_difference
- comment the fifo_stock_diff guard for non-queue predecessors

(cherry picked from commit ef5f47fafd)
2026-07-03 07:50:22 +00:00
Mihir Kandoi
3571512101 fix: FIFO queue checks and incorrect entries filter in stock ledger reports
- 'Show Incorrect Entries' always returned an empty result (regression
  from #43619); now returns entries from one row before the first
  incorrect one
- FIFO queue columns were computed for serialized/batched SLEs that
  don't maintain a stock queue, showing false differences; left empty
  for such rows
- compare value/valuation differences at currency precision, qty at
  float precision

(cherry picked from commit 94ab09e4a3)

# Conflicts:
#	erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py
2026-07-03 07:50:22 +00:00
Khushi Rawat
5034e110cf Merge pull request #56813 from frappe/mergify/bp/version-16-hotfix/pr-56811
fix: render letter head footer in print formats (backport #56811)
2026-07-03 12:11:49 +05:30
Khushi Rawat
b09514465d Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-56811 2026-07-03 11:38:18 +05:30
pandiyan
91a319c9e3 test: cover reserve stock gating on purchase receipt submit
(backport of #56798)

Co-Authored-By: pandiyan <pandiyanpalani37@gmail.com>
2026-07-03 11:06:39 +05:30
pandiyan
17598e2626 fix: skip stock reservation for opted-out production plans
(backport of #56798)

Co-Authored-By: pandiyan <pandiyanpalani37@gmail.com>
2026-07-03 11:06:29 +05:30
mergify[bot]
8c56a5ac0c fix: don't attempt to create SABB for non-serialized / non-batch items (backport #56627) (#56803)
* fix: don't attempt to create SABB for non-serialized / non-batch items (#56627)

* fix: don't attempt to create SABB for non-serialized / non-batch items

* fix(stock): skip serial batch lookup for rows without item code

(cherry picked from commit 5b738b7b0d)

# Conflicts:
#	erpnext/stock/services/serial_batch_bundle_service.py

* chore: resolve conflicts

---------

Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-03 10:35:43 +05:30
khushi8112
0371e8eaf0 fix: add page numbers to print format footer
(cherry picked from commit 2d0c0a8c09)
2026-07-02 21:13:53 +00:00
khushi8112
fa4d32dcdb fix: render letter head footer in print formats
(cherry picked from commit e60a467972)
2026-07-02 21:13:53 +00:00
Shllokkk
8907e8a492 Merge pull request #56805 from frappe/mergify/bp/version-16-hotfix/pr-56804
fix: guard against missing DocType in onboarding steps patch (backport #56804)
2026-07-03 02:12:52 +05:30
Shllokkk
f72289e27c fix: guard against missing DocType in onboarding steps patch (#56804)
(cherry picked from commit caa4358057)
2026-07-02 14:04:47 +00:00
mergify[bot]
003b6554c4 fix(company): ignore user permissions for link fields having link to Account and Cost Center (backport #56748) (#56795)
* fix(company): ignore user permissions for link fields having link to `Account` and `Cost Center` (#56748)

(cherry picked from commit 9cea43b006)

# Conflicts:
#	erpnext/setup/doctype/company/company.json

* chore: resolves conflict

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-02 12:55:16 +00:00
Mihir Kandoi
0e25e62750 Merge pull request #56712 from aerele/backport-56552-version-16-hotfix
fix: set conversion_rate on quotation created from customer
2026-07-02 15:20:39 +05:30
mergify[bot]
19d03fee46 fix: block serialized to non-serialized item change when SABB exists (backport #56773) (#56775)
* fix: block serialized to non-serialized item change when SABB exists (#56773)

(cherry picked from commit 0e8ae7548d)

# Conflicts:
#	erpnext/stock/doctype/item/item.py
#	erpnext/stock/doctype/item/test_item.py

* chore: fix conflicts

Remove validation for standard cost change and adjust serialized item change validation.

* chore: fix conflicts

Removed test for opening stock with serial and batch numbers.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-02 09:31:16 +00:00
mergify[bot]
4573cd15a9 fix: restore Save button on reverse journal entry (backport #56770) (#56772)
fix: restore Save button on reverse journal entry (#56770)

Reversing a submitted Journal Entry opened a draft with reversal_of set,
which called frm.set_read_only(). That strips the write and submit perms
from frm.perm, so the toolbar never rendered the Save (or later Submit)
button and the reversal could not be saved.

Lock the fields and the accounts grid as read_only instead, leaving perms
intact so Save and Submit still work while nothing stays editable.

Ticket: 72857
(cherry picked from commit 0a05dd4426)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-02 13:50:57 +05:30
Nabin Hait
0f9405646b Merge pull request #55970 from frappe/mergify/bp/version-16-hotfix/pr-55947
fix(accounts): clear clearance date when amending reconciled voucher (backport #55947)
2026-07-02 13:22:20 +05:30
Mihir Kandoi
84af478eec Merge pull request #56753 from aerele/backport-56670
fix: recompute transferred qty before deciding work order status
2026-07-02 13:16:16 +05:30
Mihir Kandoi
5b133dcf18 Merge pull request #56764 from frappe/mergify/bp/version-16-hotfix/pr-56757
fix: don't treat batch slot at FIFO queue head as qty slot (backport #56757)
2026-07-02 13:09:15 +05:30
Mihir Kandoi
16bc78834e test: assert full negative batch slot in ageing regression test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 8928b42d5d)
2026-07-02 07:14:31 +00:00
Mihir Kandoi
20f6dd0224 fix: don't treat batch slot at FIFO queue head as qty slot
An incoming SLE without resolvable serial/batch details hit the
negative-head branch in _compute_incoming_stock even when the head was
a batch slot, because flt() on the batch number string returns 0.0.
_add_to_negative_fifo_head then crashed with
"TypeError: can only concatenate str (not 'float') to str".

Guard the branch with is_qty_slot, mirroring the existing check in
_add_transfer_slot_to_fifo_queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c47a95a4d2)
2026-07-02 07:14:31 +00:00
ruthra kumar
d88ca43e52 Merge pull request #56756 from frappe/mergify/bp/version-16-hotfix/pr-56754
refactor: update title for process statement of accounts (backport #56754)
2026-07-02 12:16:01 +05:30
ruthra kumar
ef162f9a9e refactor: update title for process statement of accounts
(cherry picked from commit 04468c3c33)
2026-07-02 06:42:55 +00:00
pandiyan
bc952f40d9 fix: recompute transferred qty before deciding work order status
work order status was decided using a stale transferred-qty value,
computed before the current stock entry's transfer got recomputed.
this left work orders stuck at "not started" for pick-list-driven
transfers, since those entries never set fg_completed_qty and their
transferred qty can only be known from actual item-level transfers.

an earlier attempt fixed this by setting fg_completed_qty from the pick
list's for_qty, but that broke two things tied to fg_completed_qty
being zero: the excess-transfer guard, and the partial-transfer
fraction logic used to avoid marking a work order as fully supplied too
early.

recompute the transferred qty first, then decide status from the fresh
value. revert the fg_completed_qty change since it's no longer needed.
2026-07-02 11:22:57 +05:30
mergify[bot]
eea4756ce9 fix: validate reverse GL entries on current date under immutable ledger (backport #56709) (#56752)
fix: validate reverse GL entries on current date under immutable ledger (#56709)

* fix: validate reverse GL entries on current date under immutable ledger

When Immutable Ledger is enabled, the reverse GL entry is posted on the
current date, but the closed-period checks in make_reverse_gl_entries still
validate against the original (backdated) posting date. This blocks cancelling
a backdated voucher, such as a suspense Journal Entry for a migrated NPA loan,
with a books-closed error even though the reverse entry lands in an open period.

Validate both check_freezing_date and validate_against_pcv against the current
date when Immutable Ledger is enabled. When it is disabled, behaviour is
unchanged.

Follow-up to #55268.

* test: reset frozen till date after reverse entry test

The freeze date set on the company was not reset, so it leaked into the next
test which posts entries in that period. Reset it in a finally block.

* fix: prefer explicit posting_date under immutable ledger

Prefer the posting_date argument before frappe.form_dict and getdate, at both
the validation and the GL entry site, so an explicit date passed by the caller
is honoured and validation still matches the posted date.

(cherry picked from commit cab1b129c0)

Co-authored-by: Nihantra C. Patel <141945075+Nihantra-Patel@users.noreply.github.com>
2026-07-02 04:57:11 +00:00
Mihir Kandoi
8dafd2b684 Merge pull request #56742 from frappe/mergify/bp/version-16-hotfix/pr-56741
fix(item): rework multiple variant dialog for large numeric ranges (backport #56741)
2026-07-02 00:31:03 +05:30
Mihir Kandoi
04c834d6a9 fix(item): error on uncommitted input and escape values in variant dialog
Address review feedback:
- A typed-but-not-selected value passed validation yet was dropped by
  get_selected_attributes (reads committed pills only). Treat any pending
  input as an error so it is never silently omitted from creation.
- Escape pill / pending values before interpolating them into the HTML
  error message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit d4da9a3d7d)
2026-07-01 18:14:18 +00:00
Mihir Kandoi
025d0cd7f3 fix(item): rework multiple variant dialog for large numeric ranges
The 'Create Multiple Variants' dialog rendered one checkbox per attribute
value and read the numeric config from the variant attribute child row. This
broke in several ways:

- A template whose attribute was made numeric after being added kept
  numeric_values=0 on the child row, so the dialog treated it as non-numeric,
  queried the empty Item Attribute Value table, and showed no values.
- Enumerating a large range (e.g. 1-100000) into checkboxes froze the browser.

Rework the dialog:

- Read numeric_values / from_range / to_range / increment from the Item
  Attribute master, and guard increment > 0.
- Replace the checkbox-per-value list with one MultiSelectPills per attribute,
  with a search placeholder.
- Stop enumerating numeric ranges: preview the first few values and validate
  typed input against the range on demand, so huge ranges stay instant.
- Block variant creation with a modal error if any selected value or pending
  input is invalid (out of range, off-increment, or not a number), so garbage
  like '00A' can't reach creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 99152b8300)
2026-07-01 18:14:18 +00:00
Mihir Kandoi
374b340e73 fix(item-attribute): clear attribute values when marking numeric
Marking an attribute numeric hides the Item Attribute Values grid but leaves
its rows in the doc, whose mandatory Attribute Value / Abbreviation block the
save client-side before the server can clear them. Clear the table on the
client too so the save goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 4afbd4d3d9)
2026-07-01 18:14:18 +00:00
ervishnucs
26905bc142 fix: set conversion_rate on quotation created from customer 2026-07-01 22:27:14 +05:30
mergify[bot]
d9aa4491f1 feat: capture user persona during setup (backport #56705) (#56707) 2026-07-01 18:15:55 +05:30
mergify[bot]
19c318df68 fix(banking): handle blank password protected PDFs and negative amounts in CR/DR columns (backport #56690) (#56694)
fix(banking): handle blank password protected PDFs and negative amounts in CR/DR columns (#56690)

* fix(banking): strip signs from amount if column has CR/DR values

* fix(banking): try decrypting PDF with a blank password

(cherry picked from commit 300471da12)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-07-01 08:24:28 +00:00
mergify[bot]
2cb577b912 fix(banking): use custom renderer for translated strings and parser for rules (backport #56643) (#56647)
fix(banking): use custom renderer for translated strings and parser for rules (#56643)

fix(banking): use custom renderer for translated strings and parser for formula evaluation

(cherry picked from commit 8447f551e7)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-07-01 13:31:12 +05:30
mergify[bot]
53c17bf331 chore: update dependencies in banking app (backport #56685) (#56689)
chore: update dependencies in banking app (#56685)

chore: update deps in banking app
(cherry picked from commit 26583ae357)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-07-01 13:30:32 +05:30
Frappe PR Bot
fd00cebbd2 chore(release): Bumped to Version 16.26.1
## [16.26.1](https://github.com/frappe/erpnext/compare/v16.26.0...v16.26.1) (2026-07-01)

### Bug Fixes

* **stock:** support quality inspection for stock entry by purpose (backport [#56446](https://github.com/frappe/erpnext/issues/56446)) ([613b3c1](613b3c16c2))
2026-07-01 07:42:04 +00:00
Mihir Kandoi
fe6c276e72 Merge pull request #56687 from frappe/mergify/bp/version-16/pr-56672
fix(stock): support quality inspection for stock entry by purpose (ba… (backport #56672)
2026-07-01 13:10:18 +05:30
Sudharsanan11
613b3c16c2 fix(stock): support quality inspection for stock entry by purpose (backport #56446)
(cherry picked from commit 40ca3b5e5d)
2026-07-01 07:28:45 +00:00
Mihir Kandoi
e083d195cf Merge pull request #56672 from aerele/backport/qi-stock-entry-by-purpose-v16
fix(stock): support quality inspection for stock entry by purpose (ba…
2026-07-01 12:45:07 +05:30
Mihir Kandoi
7f1ef59dc5 Merge pull request #56671 from aerele/backport/dn-credit-note-per-billed-v16
fix(selling): update sales order per billed on credit note submission
2026-07-01 09:29:38 +05:30
Sudharsanan11
710e021638 test(selling): add test to validate the per billed after credit note submission 2026-07-01 09:06:01 +05:30
Sudharsanan11
224cf19f33 fix(selling): update sales order per billed on credit note submission 2026-07-01 09:06:01 +05:30
Frappe PR Bot
666becc670 chore(release): Bumped to Version 16.26.0
# [16.26.0](https://github.com/frappe/erpnext/compare/v16.25.0...v16.26.0) (2026-07-01)

### Bug Fixes

* add permission checks in whitelisted functions (backport [#53103](https://github.com/frappe/erpnext/issues/53103)) ([#56669](https://github.com/frappe/erpnext/issues/56669)) ([52ea964](52ea9641ce))
* adjust outstanding amount calculation in purchase and sales registers ([1a2a9b6](1a2a9b6cfc))
* allow rename for Quality Inspection Parameter ([410a772](410a772510))
* **asset:** conditionally show Is Fully Depreciated field ([0e730bf](0e730bf1c7))
* carry item-level project to Purchase Receipt GL entries (backport [#56568](https://github.com/frappe/erpnext/issues/56568)) ([#56619](https://github.com/frappe/erpnext/issues/56619)) ([0829cb4](0829cb45a8))
* **controllers:** fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT) ([3abadc7](3abadc7a5f))
* **crm:** using `get_list` instead of `get_all` in `get_opportunities` (backport [#56463](https://github.com/frappe/erpnext/issues/56463)) ([#56466](https://github.com/frappe/erpnext/issues/56466)) ([30ba950](30ba950abd))
* do not allow closing the accounting period for future dates (backport [#56551](https://github.com/frappe/erpnext/issues/56551)) ([#56577](https://github.com/frappe/erpnext/issues/56577)) ([df1b443](df1b4431b6))
* exclude virtual child doctypes from deletion in transaction deletion record ([8a66570](8a665709d2))
* gross profit calculation with rate adjustment entries ([9c5b063](9c5b063884))
* handle missing serial and batch bundle in print format ([c84d4a2](c84d4a282d))
* ignored posting time 00:00:00 in RIV (backport [#56571](https://github.com/frappe/erpnext/issues/56571)) ([#56573](https://github.com/frappe/erpnext/issues/56573)) ([b4d83e5](b4d83e542a))
* job card timer issue (backport [#56405](https://github.com/frappe/erpnext/issues/56405)) ([#56406](https://github.com/frappe/erpnext/issues/56406)) ([54c45d7](54c45d7b22))
* **lead:** added missing read permission check on `get_lead_details` (backport [#56272](https://github.com/frappe/erpnext/issues/56272)) ([#56274](https://github.com/frappe/erpnext/issues/56274)) ([bd54c7f](bd54c7fea8))
* **letter-head:** guard company lookups when doc has no company field ([89059a9](89059a990f))
* link portal address rows to web form ([3a480c0](3a480c08b1))
* manual backport of [#55896](https://github.com/frappe/erpnext/issues/55896) ([490e125](490e125267))
* party aliases should be no copy ([0b42241](0b42241682))
* precision issue causing COGS in inter transfer PR (backport [#56420](https://github.com/frappe/erpnext/issues/56420)) ([#56425](https://github.com/frappe/erpnext/issues/56425)) ([e3958ad](e3958ad7bb))
* remove dead bundle helper call from purchase receipt print format ([2eaa635](2eaa635ab6))
* remove frappe.utils from jinja context in process statement of accounts ([37ec2d0](37ec2d0edd))
* rewrite item rate calculation (backport [#56315](https://github.com/frappe/erpnext/issues/56315)) ([ef3d444](ef3d444a60))
* set mr status to received when per_received is 100 even if per_ordered < 100 ([4181246](41812462b4))
* show contextual balance label on party dashboard for net balances ([a886d0b](a886d0b445))
* skip qty over-allowance check for non-stock items only ([bc313dc](bc313dc09d))
* **stock:** value batch/serial return from ledger when original receipt has no bundle (backport [#56631](https://github.com/frappe/erpnext/issues/56631)) ([#56646](https://github.com/frappe/erpnext/issues/56646)) ([2c18c16](2c18c16be6))
* sync Stock Reconciliation difference amount with GL after reposting (backport [#56574](https://github.com/frappe/erpnext/issues/56574)) ([#56585](https://github.com/frappe/erpnext/issues/56585)) ([e834098](e834098c28))
* update qty in future SLEs when cancelling documents ([#56638](https://github.com/frappe/erpnext/issues/56638)) ([01374db](01374db8da))
* update_qty_in_future_sle skips SLEs with same posting datetime ([#56612](https://github.com/frappe/erpnext/issues/56612)) ([5aa62d1](5aa62d1cda))
* Use correct doctype name for PCV perm-check (backport [#56606](https://github.com/frappe/erpnext/issues/56606)) ([#56611](https://github.com/frappe/erpnext/issues/56611)) ([762ce5c](762ce5c684))
* use correct variable to fetch valuation method ([d2c8df9](d2c8df9451))

### Features

* **accounts:** add configurable job timeout for Process Period Closing Voucher ([d389014](d389014e57))
2026-07-01 03:34:53 +00:00
Diptanil Saha
628b932d55 Merge pull request #56652 from frappe/version-16-hotfix
chore: release v16
2026-07-01 09:03:17 +05:30
Diptanil Saha
b5c09fa993 Merge pull request #56680 from frappe/mergify/bp/version-16-hotfix/pr-56678
fix(gross_profit): correct GP calculation for rate adjustment debit notes (backport #56678)
2026-07-01 08:42:13 +05:30
diptanilsaha
66f99668a9 test(gross_profit): added test cases for rate adjustment entry
(cherry picked from commit 17ef5d6034)
2026-07-01 03:02:44 +00:00
diptanilsaha
9c5b063884 fix: gross profit calculation with rate adjustment entries
(cherry picked from commit b9f330a158)
2026-07-01 03:02:44 +00:00
Sudharsanan11
40ca3b5e5d fix(stock): support quality inspection for stock entry by purpose (backport #56446) 2026-07-01 00:16:21 +05:30
mergify[bot]
52ea9641ce fix: add permission checks in whitelisted functions (backport #53103) (#56669)
* fix: add permission checks in whitelisted functions (#53103)

(cherry picked from commit a6e78c2eea)

# Conflicts:
#	erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py

* chore: resolve conflict

---------

Co-authored-by: Priyal Rawal <135015851+Priyal208@users.noreply.github.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-30 16:36:13 +00:00
Diptanil Saha
7396fca4be Merge branch 'version-16' into version-16-hotfix 2026-06-30 22:02:57 +05:30
Mihir Kandoi
38994ec915 Merge pull request #56664 from frappe/mergify/bp/version-16-hotfix/pr-56662
fix: use correct variable to fetch valuation method (backport #56662)
2026-06-30 20:45:25 +05:30
Diptanil Saha
a1412a61c9 refactor: port bulk_transaction_log existence check to the query builder (#56659)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:44:11 +05:30
ruthra kumar
62703de452 Merge pull request #56658 from frappe/mergify/bp/version-16-hotfix/pr-56655
ci: warmup test data along with DB (backport #56655)
2026-06-30 20:37:34 +05:30
Mihir Kandoi
d2c8df9451 fix: use correct variable to fetch valuation method
(cherry picked from commit 1492c9fbc3)
2026-06-30 15:04:46 +00:00
ruthra kumar
05aa68b53b ci: warmup test data along with DB
(cherry picked from commit dcdbf9df17)
2026-06-30 12:31:03 +00:00
Khushi Rawat
169a4c82a4 Merge pull request #56649 from frappe/mergify/bp/version-16-hotfix/pr-56569
fix(asset): conditionally show Is Fully Depreciated field (backport #56569)
2026-06-30 15:55:20 +05:30
Mohd Haris
0e730bf1c7 fix(asset): conditionally show Is Fully Depreciated field
The "Is Fully Depreciated" field was hidden on the Asset form (hidden: 1),
so it could never be set for manually entered existing assets.

Make it visible based on context:
- Existing Asset with Calculate Depreciation off -> visible and editable
- Calculate Depreciation on -> visible but read-only and forced unchecked
  (it is only meaningful for manually entered assets)

The unchecked value is enforced in the form script (immediate feedback on
toggle and on load) and in server-side validate() so it can never be saved
as checked while depreciation is being calculated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit a7c1ebacbe)
2026-06-30 10:05:12 +00:00
mergify[bot]
2c18c16be6 fix(stock): value batch/serial return from ledger when original receipt has no bundle (backport #56631) (#56646)
fix(stock): value batch/serial return from ledger when original receipt has no bundle (#56631)

* fix(stock): value batch/serial return from ledger when original receipt has no bundle

* test(stock): add test to validate the valuation of serial/batch for return when original receipt has no bundle

(cherry picked from commit 6184c057db)

# Conflicts:
#	erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-06-30 15:34:51 +05:30
rohitwaghchaure
01374db8da fix: update qty in future SLEs when cancelling documents (#56638) 2026-06-30 14:27:46 +05:30
Mihir Kandoi
5d8f653476 Merge pull request #56604 from frappe/mergify/bp/version-16-hotfix/pr-56231
fix(controllers): fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT) (backport #56231)
2026-06-30 12:03:21 +05:30
Mihir Kandoi
55716a509f Merge pull request #56636 from frappe/mergify/bp/version-16-hotfix/pr-56621
fix: set mr status to received when per_received is 100 even if per_o… (backport #56621)
2026-06-30 12:01:53 +05:30
MochaMind
c782658056 chore: sync translations to version-16-hotfix (#56617) 2026-06-30 11:24:54 +05:30
pandiyan
41812462b4 fix: set mr status to received when per_received is 100 even if per_ordered < 100
(cherry picked from commit a3c5ef6aa3)
2026-06-30 05:41:09 +00:00
mergify[bot]
0829cb45a8 fix: carry item-level project to Purchase Receipt GL entries (backport #56568) (#56619)
fix: carry item-level project to Purchase Receipt GL entries (#56568)

Purchase Receipt stock and asset GL lines used the item row's cost center
but always fell back to the document-level project, unlike Purchase Invoice
which uses the item-level project. add_gl_entry accepted a project argument
but never wrote it to the GL dict, so the inward, Stock Received But Not
Billed, landed cost, divisional loss, sub-contracting and exchange rate
lines dropped the row's project.

Write project into the GL dict and pass project=item.project on the entries
that were missing it, so project behaves like cost center and matches
Purchase Invoice.

Ticket: 72523
(cherry picked from commit 6f97c7199c)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-06-29 23:30:48 +05:30
mergify[bot]
762ce5c684 fix: Use correct doctype name for PCV perm-check (backport #56606) (#56611)
fix: Use correct doctype name for PCV perm-check (#56606)

closes https://github.com/frappe/erpnext/issues/56593

(cherry picked from commit 747374e767)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2026-06-29 19:04:47 +05:30
rohitwaghchaure
5aa62d1cda fix: update_qty_in_future_sle skips SLEs with same posting datetime (#56612) 2026-06-29 15:38:35 +05:30
Mihir Kandoi
3abadc7a5f fix(controllers): fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT)
rfq_transaction_list had two defects introduced when it was converted to the query
builder:

1. `party.supplier == party[0]` compared supplier to a column literally named "0"
   (a stray index on the DocType, not the intended `parties[0]` value). This renders
   as `supplier = \`0\`` / `supplier = "0"` and errors on BOTH engines
   (MariaDB: Unknown column '0'; Postgres: column "0" does not exist), so the
   supplier portal RFQ list was completely broken.
2. SELECT DISTINCT ordered by `creation`, which is not in the select list. Postgres
   rejects this ("for SELECT DISTINCT, ORDER BY expressions must appear in select list").

Compare against `parties[0]` and add `creation` to the select list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a7d9078bf4)

# Conflicts:
#	erpnext/controllers/tests/test_website_list_for_contact.py
2026-06-29 08:24:18 +00:00
ruthra kumar
1a348d8e6e Merge pull request #56598 from frappe/mergify/bp/version-16-hotfix/pr-54597
fix: adjust outstanding amount calculation in purchase and sales registers (backport #54597)
2026-06-29 11:30:40 +05:30
ruthra kumar
1eed77e605 Merge pull request #56038 from frappe/mergify/bp/version-16-hotfix/pr-56034
refactor(test): remove custom utility for company creation (backport #56034)
2026-06-29 11:19:49 +05:30
ruthra kumar
490e125267 fix: manual backport of #55896 2026-06-29 11:09:23 +05:30
Sowmiya P K
1a2a9b6cfc fix: adjust outstanding amount calculation in purchase and sales registers
(cherry picked from commit 6115af720b)

# Conflicts:
#	erpnext/accounts/report/purchase_register/purchase_register.py
2026-06-29 10:52:49 +05:30
ruthra kumar
93b28f3337 refactor(test): update assertion for new test records
(cherry picked from commit afeaba5142)
2026-06-29 09:41:48 +05:30
ruthra kumar
1a9fcfb0cc refactor(test): broken test case in accounts controller
(cherry picked from commit 59a69fc497)
2026-06-29 09:41:48 +05:30
ruthra kumar
a8aa73baae refactor(test): remove even more dead code
(cherry picked from commit 3d109571ee)
2026-06-29 09:41:48 +05:30
ruthra kumar
65202d2be1 refactor(tests): reuse persistent master data instead of creating company per test
Replace per-test company creation in setUp() with persistent master data
from BootStrapTestData. Add Test PCV Company to test_records.json so it
becomes a persistent fixture rather than a throwaway created per test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 6e62750c2f)

# Conflicts:
#	erpnext/accounts/report/financial_ratios/test_financial_ratios.py
2026-06-29 09:41:47 +05:30
MochaMind
1cc2890f3e chore: update POT file (#56591) 2026-06-28 21:26:46 +02:00
mergify[bot]
e834098c28 fix: sync Stock Reconciliation difference amount with GL after reposting (backport #56574) (#56585)
* fix: sync Stock Reconciliation difference amount with GL after reposting (#56574)

* fix: sync Stock Reconciliation difference amount with GL after reposting

* fix: placement of recalculate differece amount function

(cherry picked from commit c7ef42ef98)

# Conflicts:
#	erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
#	erpnext/stock/stock_ledger.py

* chore: fix conflicts

Removed unused import and fixed import order.

* chore: fix conflicts

Refactor update_rate_on_stock_reconciliation to use recalculation method for difference amount.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-06-27 17:08:54 +05:30
Mihir Kandoi
655d6dac87 Merge pull request #56587 from frappe/mergify/bp/version-16-hotfix/pr-56586
fix: party aliases should be no copy (backport #56586)
2026-06-27 16:44:33 +05:30
Shllokkk
36a9c7a0d3 Merge pull request #56386 from frappe/mergify/bp/version-16-hotfix/pr-56337
fix: show contextual balance label on party dashboard for net balances (backport #56337)
2026-06-27 16:36:07 +05:30
Mihir Kandoi
b35c5c1568 chore: resolve conflicts 2026-06-27 16:34:53 +05:30
Mihir Kandoi
e64e9866fb chore: fix conflicts 2026-06-27 16:34:20 +05:30
mergify[bot]
b292be34cb chore(crm_settings): remove unused delete_custom_fields import (backport #56558) (#56583)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-27 16:29:48 +05:30
Mihir Kandoi
0b42241682 fix: party aliases should be no copy
(cherry picked from commit 057af21cd8)

# Conflicts:
#	erpnext/buying/doctype/supplier/supplier.json
#	erpnext/selling/doctype/customer/customer.json
2026-06-27 10:55:31 +00:00
mergify[bot]
df1b4431b6 fix: do not allow closing the accounting period for future dates (backport #56551) (#56577)
fix: do not allow closing the accounting period for future dates (#56551)

(cherry picked from commit 5e60e4faa7)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-06-27 12:04:11 +05:30
mergify[bot]
112b773193 chore: removing controllers from pre-commit eslint hooks exclude list (backport #56575) (#56579)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-27 01:37:18 +05:30
mergify[bot]
b4d83e542a fix: ignored posting time 00:00:00 in RIV (backport #56571) (#56573)
fix: ignored posting time 00:00:00 in RIV (#56571)

(cherry picked from commit 31f89b72b4)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-06-26 19:42:45 +05:30
mergify[bot]
9bc240c4f0 ci: bump pre-commit actions to v3.0.1 (backport #56562) (#56567)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-26 17:06:08 +05:30
Mihir Kandoi
1b7486f61a Merge pull request #56565 from frappe/mergify/bp/version-16-hotfix/pr-56559
fix: remove dead bundle helper call from purchase receipt print format (backport #56559)
2026-06-26 16:54:11 +05:30
Shllokkk
2eaa635ab6 fix: remove dead bundle helper call from purchase receipt print format
(cherry picked from commit b9f5a77fa7)
2026-06-26 11:07:51 +00:00
Mihir Kandoi
733a837c18 Merge pull request #56550 from frappe/mergify/bp/version-16-hotfix/pr-56127
fix: allow rename for Quality Inspection Parameter (backport #56127)
2026-06-26 15:56:43 +05:30
Shllokkk
a886d0b445 fix: show contextual balance label on party dashboard for net balances
(cherry picked from commit 3251b40365)
2026-06-26 15:03:06 +05:30
Mohd Haris
410a772510 fix: allow rename for Quality Inspection Parameter
The Quality Inspection Parameter DocType did not have `allow_rename`
enabled, so the "Rename" action was hidden from the form's menu
(the 3-dots / ⋮ options). Since the DocType is auto-named from the
`parameter` field (`autoname: field:parameter`), users had no way to
correct or change a parameter's name once created.

Enable `allow_rename` so users can rename a Quality Inspection
Parameter from the form menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit adfef48a65)
2026-06-26 08:15:17 +00:00
Mihir Kandoi
2404074b9d Merge pull request #56512 from frappe/revert-56500-mergify/bp/version-16-hotfix/pr-56497
Revert "fix: handle missing serial and batch bundle in print format (backport #56497)"
2026-06-26 12:56:19 +05:30
Mihir Kandoi
5f02ff5f8c Revert "fix: handle missing serial and batch bundle in print format (backport #56497)" 2026-06-26 10:08:25 +05:30
rohitwaghchaure
6dad1b1ae6 refactor: move Inventory Dimension mandatory check from field-level to server-side (#56451)
* refactor: move Inventory Dimension mandatory check from field-level to server-side

* refactor: split large function

* fix: greptile issue
2026-06-25 20:10:09 +05:30
Shllokkk
a00874fa95 Merge pull request #56500 from frappe/mergify/bp/version-16-hotfix/pr-56497
fix: handle missing serial and batch bundle in print format (backport #56497)
2026-06-25 19:33:50 +05:30
Shllokkk
c84d4a282d fix: handle missing serial and batch bundle in print format
(cherry picked from commit 548d90df4f)
2026-06-25 13:53:11 +00:00
mergify[bot]
30ba950abd fix(crm): using get_list instead of get_all in get_opportunities (backport #56463) (#56466)
* fix(crm): using `get_list` instead of `get_all` in `get_opportunities` (#56463)

(cherry picked from commit 9b4c8a8d7f)

# Conflicts:
#	erpnext/crm/doctype/prospect/prospect.py

* chore: resolve conflict

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-25 10:23:04 +00:00
mergify[bot]
ef3d444a60 fix: rewrite item rate calculation (backport #56315)
Co-authored-by: Harsh Patadia <harsh@Harshs-MacBook-Air.local>
Co-authored-by: Sagar Vora <16315650+sagarvora@users.noreply.github.com>
Co-authored-by: Smit Vora <mailsmitvora@gmail.com>
2026-06-25 09:50:50 +00:00
Shllokkk
4e94b75b5d Merge pull request #56460 from Shllokkk/psoa-restrict-jinja-context-v16
fix: remove frappe.utils from jinja context in process statement of accounts
2026-06-25 15:17:29 +05:30
mergify[bot]
831d25bed7 ci: bump po review action (backport #56454) (#56461)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-06-25 09:42:32 +00:00
Shllokkk
37ec2d0edd fix: remove frappe.utils from jinja context in process statement of accounts 2026-06-25 14:55:47 +05:30
Khushi Rawat
85ba1231f8 Merge pull request #56444 from frappe/mergify/bp/version-16-hotfix/pr-56432
fix(letter-head): guard company lookups when doc has no company field (backport #56432)
2026-06-25 14:38:08 +05:30
Mihir Kandoi
a24b690a14 Merge pull request #56452 from mihir-kandoi/mariadb-ci-fanout-v16
ci(mariadb): self-hosted fan-out MariaDB CI (v16 backport of #56410)
2026-06-25 14:25:43 +05:30
Mihir Kandoi
8dd37b6df0 ci(mariadb): self-hosted fan-out MariaDB CI
Backport of #56410 to version-16-hotfix. v16 matches develop on Python 3.14 /
Node 24 / --lightmode / payments branch and the
ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24 image, so the fan-out workflow
and helpers (start-db.sh, hydrate.sh) apply verbatim; the frappe framework
branch resolves automatically from GITHUB_BASE_REF.
2026-06-25 14:15:18 +05:30
Mihir Kandoi
de70f2cceb Merge pull request #56307 from frappe/mergify/bp/version-16-hotfix/pr-56306
fix: link portal address rows to web form (backport #56306)
2026-06-25 13:51:07 +05:30
Ejaaz Khan
89059a990f fix(letter-head): guard company lookups when doc has no company field
(cherry picked from commit 7cb03a427a)

# Conflicts:
#	erpnext/accounts/letter_head/company_letterhead/company_letterhead.json
#	erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json
#	erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json
2026-06-25 05:42:22 +00:00
Mihir Kandoi
a9a371e4a4 Merge pull request #56433 from aerele/backport-56376
fix: skip over-allowance qty validation for non-stock items (backport #56335)
2026-06-24 20:03:15 +05:30
mergify[bot]
bd54c7fea8 fix(lead): added missing read permission check on get_lead_details (backport #56272) (#56274)
fix(lead): added missing read permission check on `get_lead_details` (backport #56272)
2026-06-24 19:42:20 +05:30
pandiyan
0c502eaa18 test: add tests for non stock item over billing against so/po 2026-06-24 18:47:59 +05:30
mergify[bot]
e3958ad7bb fix: precision issue causing COGS in inter transfer PR (backport #56420) (#56425)
fix: precision issue causing COGS in inter transfer PR (#56420)

(cherry picked from commit 9b0e1b61f2)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-06-24 18:30:32 +05:30
pandiyan
bc313dc09d fix: skip qty over-allowance check for non-stock items only 2026-06-24 18:12:42 +05:30
Mihir Kandoi
5c716b0547 Merge pull request #56430 from frappe/mergify/bp/version-16-hotfix/pr-55191
refactor(sales_person_wise_transaction_summary): Replace SQL with que… (backport #55191)
2026-06-24 16:43:29 +05:30
Loic Oberle
b3871a212c refactor(sales_person_wise_transaction_summary): Replace SQL with que… (#55191)
(cherry picked from commit df3d0859a1)
2026-06-24 10:44:33 +00:00
Frappe PR Bot
e9a9224ec4 chore(release): Bumped to Version 16.25.0
# [16.25.0](https://github.com/frappe/erpnext/compare/v16.24.0...v16.25.0) (2026-06-24)

### Features

* **accounts:** add configurable job timeout for Process Period Closing Voucher ([e71b066](e71b066eec))
2026-06-24 10:43:40 +00:00
ruthra kumar
b08de1f1e5 Merge pull request #56427 from frappe/mergify/bp/version-16/pr-56418
refactor: configurable timeout on process pcv (backport #56417) (backport #56418)
2026-06-24 16:12:00 +05:30
Mihir Kandoi
18400b58ce Merge pull request #56423 from frappe/mergify/bp/version-16-hotfix/pr-56421
fix: exclude virtual child doctypes from deletion in transaction dele… (backport #56421)
2026-06-24 15:37:18 +05:30
ruthra kumar
e46be25e9f chore: resolve conflicts
(cherry picked from commit df3c821f98)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.py
#	erpnext/patches.txt
2026-06-24 15:36:07 +05:30
ruthra kumar
570c67bb34 refactor: patch, display depends on and json changes
(cherry picked from commit 3da7eefebb)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.py
#	erpnext/patches.txt
(cherry picked from commit c33d7e5d7b)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.py
#	erpnext/patches.txt
2026-06-24 10:00:58 +00:00
ruthra kumar
e71b066eec feat(accounts): add configurable job timeout for Process Period Closing Voucher
Adds a `pcv_job_timeout` Int field (default 3600s) to Accounts Settings
so admins can tune the enqueue timeout for PCV background jobs without
a code change. All three `frappe.enqueue` calls in
`process_period_closing_voucher.py` now read this value at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 13b6c4a165)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json
(cherry picked from commit c97be8abe1)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json
2026-06-24 10:00:57 +00:00
ruthra kumar
eee826dfb6 Merge pull request #56419 from frappe/mergify/bp/version-16-hotfix/pr-56417
refactor: configurable timeout on process pcv (backport #56417)
2026-06-24 15:30:14 +05:30
Mihir Kandoi
8a665709d2 fix: exclude virtual child doctypes from deletion in transaction deletion record
(cherry picked from commit 8bd8b28207)
2026-06-24 09:48:25 +00:00
ruthra kumar
267086153b chore: resolve conflicts 2026-06-24 15:08:04 +05:30
ruthra kumar
66b28cf456 refactor: patch, display depends on and json changes
(cherry picked from commit 3da7eefebb)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.py
#	erpnext/patches.txt
2026-06-24 07:58:49 +00:00
ruthra kumar
d389014e57 feat(accounts): add configurable job timeout for Process Period Closing Voucher
Adds a `pcv_job_timeout` Int field (default 3600s) to Accounts Settings
so admins can tune the enqueue timeout for PCV background jobs without
a code change. All three `frappe.enqueue` calls in
`process_period_closing_voucher.py` now read this value at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 13b6c4a165)
2026-06-24 07:58:48 +00:00
mergify[bot]
54c45d7b22 fix: job card timer issue (backport #56405) (#56406)
fix: job card timer issue (#56405)

(cherry picked from commit 21541e3ad3)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-06-24 09:21:02 +05:30
Mihir Kandoi
3a480c08b1 fix: link portal address rows to web form
(cherry picked from commit 5008b82f90)
2026-06-22 09:59:53 +00:00
Nabin Hait
6f281356a6 fix(accounts): clear clearance date when amending reconciled voucher
The framework ignores `no_copy` while amending, so a reconciled voucher
carried a stale clearance date into its amendment even though the linked
bank transaction gets unreconciled on cancellation. Reset it via a shared
`before_insert` hook on AccountsController.

Fixes #54909

(cherry picked from commit 1a8d73cbbe)
2026-06-16 09:59:41 +00:00
243 changed files with 201005 additions and 69015 deletions

72
.github/helper/hydrate.sh vendored Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
#
# Hydrate a test shard from the setup job's artifact.
#
# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the
# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no
# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis
# so tests can run. The whole point is that the expensive work happened ONCE in the setup job.
#
set -e
ci_user="${ERPNEXT_CI_USER:-frappe}"
db_host="${DB_HOST:-127.0.0.1}"
# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as
# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci.
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
exec su -m "$ci_user" -s /bin/bash -c \
"ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' DB='${DB:-}' bash '$0'"
fi
cd ~/frappe-bench
# Start the DB on the datadir baked into the artifact. It's already populated (the setup job
# reinstalled into this very datadir), so there is NO restore — the server comes up on the
# existing files. This is what replaces the per-shard SQL replay.
bash ~/frappe-bench/start-db.sh
# Bring up redis (lightmode unit tests need cache + queue). In the self-hosted container we use the
# full `bench start` (web/workers too, like install.sh). On the bare GitHub Postgres shard
# `bench start` (honcho) lagged — it blocks the redis procs behind web/worker procs the lightmode
# suite never uses, so the wait below burned its full timeout (~4m). There, start the two redis
# instances directly: fast and deterministic.
if [ "${DB:-mariadb}" = "postgres" ]; then
# Start redis directly as daemons — reliable and persists across steps. Do NOT route it through
# `bench start`: honcho tears the whole process group down if any one Procfile proc dies on the
# bare shard, which took redis with it (redis @ 13000 refused in Run Tests). Keeping redis
# independent is what makes it survive. The web server (for PDF tests) is NOT started here — a
# backgrounded server doesn't survive into the next step; it's started inside the Run Tests step.
for conf in redis_cache redis_queue; do
[ -f ~/frappe-bench/config/$conf.conf ] && redis-server ~/frappe-bench/config/$conf.conf --daemonize yes
done
else
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
fi
# Wait for redis, failing fast instead of silently burning minutes if it never comes up.
cfg=~/frappe-bench/sites/common_site_config.json
if [ -f "$cfg" ]; then
ports=$(python - "$cfg" <<'PY'
import json, re, sys
try:
cfg = json.load(open(sys.argv[1]))
except Exception:
sys.exit(0)
for key in ("redis_cache", "redis_queue"):
m = re.search(r":(\d+)", str(cfg.get(key, "")))
if m:
print(m.group(1))
PY
)
for port in $ports; do
up=0
for _ in $(seq 1 60); do
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then exec 3>&- 3<&-; up=1; break; fi
sleep 1
done
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; exit 1; }
done
fi
echo "Hydrated: DB up on baked datadir, redis up — ready for tests."

View File

@@ -7,21 +7,106 @@ cd ~ || exit
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
db_host=${DB_HOST:-"127.0.0.1"}
db_user_host=${DB_USER_HOST:-"localhost"}
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
bench_cache_dir=${BENCH_CACHE_DIR:-}
run_as_ci_user_if_needed() {
if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then
return
fi
local missing_packages=()
if ! command -v pkg-config >/dev/null 2>&1; then
missing_packages+=("pkg-config")
fi
if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then
missing_packages+=("libmariadb-dev")
fi
if ! command -v crontab >/dev/null 2>&1; then
missing_packages+=("cron")
fi
if [ "${#missing_packages[@]}" -gt 0 ]; then
apt-get update
apt-get install -y --no-install-recommends "${missing_packages[@]}"
fi
local ci_user="${ERPNEXT_CI_USER:-frappe}"
if ! id "$ci_user" >/dev/null 2>&1; then
useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user"
fi
rm -rf ~/frappe ~/frappe-bench
local ci_dirs=(
"$HOME"
"$GITHUB_WORKSPACE"
"$HOME/.cache"
"${PIP_CACHE_DIR:-$HOME/.cache/pip}"
"${npm_config_cache:-$HOME/.npm}"
"${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}"
"$HOME/.yarn"
"${UV_CACHE_DIR:-$HOME/.cache/uv}"
"$(dirname "$wkhtmltox_deb")"
)
if [ -n "$bench_cache_dir" ]; then
ci_dirs+=("$bench_cache_dir")
fi
# Create + own (non-recursively) the home/cache/workspace dirs before dropping to
# the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent
# cache (mounted volume or baked image layer) stays warm across runs.
mkdir -p "${ci_dirs[@]}" "$HOME/.yarn"
chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn"
export ERPNEXT_CI_NON_ROOT=1
exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'"
}
run_as_ci_user_if_needed
run_ci_step() {
local label=$1
shift
echo "::group::${label}"
date -u
local exit_code=0
timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-1800}" "$@" || exit_code=$?
date -u
echo "::endgroup::"
return "$exit_code"
}
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
git config --global --add safe.directory "$GITHUB_WORKSPACE" || true
git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true
fi
rm -rf ~/frappe ~/frappe-bench
# ---------------------------------------------------------------------------
# Phase 1 — parallelise the three slow, independent setup steps:
# a) system packages b) frappe-bench pip install c) frappe git fetch
# ---------------------------------------------------------------------------
sudo apt update
if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then
sudo apt-get update
# apt remove/install must run sequentially but can overlap with pip and git.
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
apt_pid=$!
# apt remove/install must run sequentially but can overlap with pip and git.
sudo apt-get remove -y mysql-server mysql-client
sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev &
apt_pid=$!
pip install frappe-bench &
pip_pid=$!
pip install frappe-bench &
pip_pid=$!
else
apt_pid=
pip_pid=
fi
mkdir frappe
(
@@ -32,76 +117,247 @@ mkdir frappe
) &
clone_pid=$!
wait $apt_pid
wait $pip_pid
if [ -n "$apt_pid" ]; then wait $apt_pid; fi
if [ -n "$pip_pid" ]; then wait $pip_pid; fi
wait $clone_pid
pushd frappe
git checkout FETCH_HEAD
popd
frappe_sha=$(git -C frappe rev-parse HEAD)
get_bench_cache_archive() {
if [ -z "$bench_cache_dir" ]; then
return
fi
mkdir -p "$bench_cache_dir"
# Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because
# restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a
# constantly-moving develop still hit the cache.
local cache_key
cache_key=$(
{
uname -m
python --version
node --version
bench --version
} | sha256sum | awk '{print $1}'
)
echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst"
}
restore_warm_bench() {
bench_cache_archive=$(get_bench_cache_archive)
[ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1
echo "Restoring base bench from ${bench_cache_archive}"
tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1
[ -d ~/frappe-bench/apps/frappe/.git ] || return 1
mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs
[ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt
[ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json
# Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then
# rebuild only what changed. The editable install means the venv tracks the new code with
# no reinstall. Any failure returns non-zero so the caller falls back to a full bench init.
if ! (
cd ~/frappe-bench/apps/frappe || exit 1
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
# straight from it (bench init names the remote 'upstream', not 'origin', and points
# it at this local clone — so a plain `git fetch origin` does not work).
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
git checkout --force FETCH_HEAD || exit 1
); then
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
rm -rf ~/frappe-bench
return 1
fi
# Pick up any frappe dependency changes since the base was built (cached → fast if none),
# so a develop commit that bumped requirements doesn't leave a stale venv.
if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then
echo "frappe dependency refresh failed; falling back to full init"
rm -rf ~/frappe-bench
return 1
fi
( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; }
return 0
}
save_warm_bench() {
if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then
return
fi
if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then
echo "Skipping warm bench save because ${bench_cache_dir} is not writable"
return
fi
local tmp_archive
tmp_archive="${bench_cache_archive}.${$}.tmp"
echo "Saving warm bench to ${bench_cache_archive}"
# Keep sites/common_site_config.json (the redis ports live there — dropping it makes the
# restore path fall back to a default redis port that bench start never bound, so reinstall
# fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded;
# restore_warm_bench runs `bench build` to regenerate it.
tar \
--use-compress-program="zstd -T0 -3" \
--exclude="frappe-bench/logs" \
--exclude="frappe-bench/sites/assets" \
-cf "$tmp_archive" \
-C ~ frappe-bench
mv "$tmp_archive" "$bench_cache_archive"
}
# ---------------------------------------------------------------------------
# Phase 2 — bench init and site setup
# ---------------------------------------------------------------------------
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
install_whktml() {
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
if [ ! -f "$wkhtmltox_deb" ]; then
wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
fi
sudo apt-get install -y "$wkhtmltox_deb"
}
if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then
install_whktml &
wkpid=$!
else
wkpid=
fi
mkdir ~/frappe-bench/sites/test_site
if ! restore_warm_bench; then
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
cd ~/frappe-bench || exit
sed -i 's/watch:/# watch:/g' Procfile
sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
CI=Yes bench build --app frappe
save_warm_bench
fi
if [ -n "$wkpid" ]; then wait $wkpid; fi
mkdir -p ~/frappe-bench/sites/test_site
if [ "$DB" == "mariadb" ];then
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
if [ "$db_host" != "127.0.0.1" ]; then
sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json
fi
else
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
fi
if [ "$DB" == "mariadb" ];then
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
for _ in {1..60}; do
if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then
break
fi
sleep 1
done
mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent
# Belt-and-suspenders: also set performance variables at runtime in case
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
# Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB
# 10.6, so it can't be disabled here — would need a server startup flag.)
mariadb --host "$db_host" --port 3306 -u root -proot \
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
# Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during
# reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in
# the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if
# reinstall then errors on table creation, unset it (off by default — zero risk).
if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;"
fi
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
fi
if [ "$DB" == "postgres" ];then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
# Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which
# dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a
# service-container DB and never calls start-db.sh, so the flags must be applied here.
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
-c "ALTER SYSTEM SET fsync = 'off'" \
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
-c "SELECT pg_reload_conf()";
fi
install_whktml() {
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
if [ ! -f /tmp/wkhtmltox.deb ]; then
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
fi
sudo apt install /tmp/wkhtmltox.deb
}
install_whktml &
wkpid=$!
cd ~/frappe-bench || exit
sed -i 's/watch:/# watch:/g' Procfile
sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
run_ci_step "Get payments app" bench get-app payments --branch develop
bench get-app payments --branch develop
bench get-app erpnext "${GITHUB_WORKSPACE}"
# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF
# tests (print formats) do — they pass only if the PDF renderer ignores missing assets.
# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it.
erpnext_get_app_args=()
if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi
run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}"
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi
wait $wkpid
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes
# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the
# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention
# then slows the job rather than failing it.
wait_for_redis() {
local cfg=~/frappe-bench/sites/common_site_config.json
[ -f "$cfg" ] || return 0
local ports port
ports=$(python - "$cfg" <<'PY'
import json, re, sys
try:
cfg = json.load(open(sys.argv[1]))
except Exception:
sys.exit(0)
for key in ("redis_cache", "redis_queue"):
match = re.search(r":(\d+)", str(cfg.get(key, "")))
if match:
print(match.group(1))
PY
)
for port in $ports; do
local up=0
for _ in $(seq 1 120); do
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
exec 3>&- 3<&-; up=1
break
fi
sleep 1
done
# Fail clearly instead of letting reinstall die later on a vague socket-connection error
# when redis never bound.
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; }
done
}
wait_for_redis
# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python
# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test
# shards start the DB on the baked datadir instead of repeating the reinstall.
run_ci_step "Reinstall test site" bench --site test_site reinstall --yes

79
.github/helper/start-db.sh vendored Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
#
# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be
# packaged into the bench artifact, test shards start an already-loaded server instead of
# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept.
#
# CI_DB_DATADIR picks the path:
# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench`
# doesn't wipe it; it's moved into the bench just before packaging)
# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it)
#
# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards).
#
set -e
ci_user="${ERPNEXT_CI_USER:-frappe}"
# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is
# refused anyway). Mirrors install.sh's user switch.
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
exec su -m "$ci_user" -s /bin/bash -c \
"ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' DB='${DB:-}' bash '$0'"
fi
# --- PostgreSQL (GitHub-hosted CI): run in-runner on a PGDATA so it bakes into the artifact,
# same idea as the mariadb datadir. Trust auth (throwaway CI) skips password setup; durability
# off for speed. Postgres is preinstalled on ubuntu-latest under /usr/lib/postgresql/<ver>/bin.
if [ "${DB:-mariadb}" = "postgres" ]; then
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin 2>/dev/null | sort -V | tail -1)
[ -n "$PG_BIN" ] && export PATH="$PG_BIN:$PATH"
PGDATA="${CI_DB_DATADIR:-$HOME/frappe-bench/pgdata}"
if [ ! -d "$PGDATA/base" ]; then
initdb -D "$PGDATA" -U postgres --auth-local=trust --auth-host=trust >/dev/null
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
fi
pg_ctl -D "$PGDATA" -w -o "-p 5432 -c listen_addresses=127.0.0.1 -c unix_socket_directories=$PGDATA -c fsync=off -c synchronous_commit=off -c full_page_writes=off" start
echo "PostgreSQL up in-runner (pgdata=$PGDATA)"
exit 0
fi
# --- MariaDB ---
DATADIR="${CI_DB_DATADIR:-$HOME/frappe-bench/mariadb-data}"
SOCK="$DATADIR/mysqld.sock"
fresh=0
if [ ! -d "$DATADIR/mysql" ]; then
mkdir -p "$DATADIR"
mariadb-install-db --no-defaults --datadir="$DATADIR" \
--auth-root-authentication-method=normal --skip-test-db >/dev/null 2>&1
fresh=1
fi
# Throwaway-CI durability off; bind TCP 127.0.0.1:3306 so bench/install.sh connect as usual.
mariadbd --no-defaults --datadir="$DATADIR" --socket="$SOCK" --pid-file="$DATADIR/mysqld.pid" \
--port=3306 --bind-address=127.0.0.1 \
--innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-log-bin \
> "$HOME/mariadb.log" 2>&1 &
up=0
for _ in $(seq 1 60); do
if mariadb-admin --socket="$SOCK" ping --silent 2>/dev/null; then up=1; break; fi
sleep 1
done
# Fail loudly instead of letting the loop fall through (exit 0 of the last `sleep`) into SQL that
# would error with a vague socket-connection failure.
[ "$up" = "1" ] || { echo "mariadbd did not come up on $SOCK"; cat "$HOME/mariadb.log" 2>/dev/null; exit 1; }
if [ "$fresh" = "1" ]; then
# A fresh datadir has only a password-less root@localhost. Give it the password install.sh
# uses, plus a TCP-reachable root@127.0.0.1, so the rest of install.sh works unchanged.
mariadb --no-defaults --socket="$SOCK" -u root <<'SQL'
ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
CREATE USER IF NOT EXISTS 'root'@'127.0.0.1' IDENTIFIED BY 'root';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SQL
fi
echo "MariaDB up in-container (datadir=$DATADIR, fresh=$fresh)"

View File

@@ -21,7 +21,7 @@ jobs:
cache: pip
- name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.0
uses: pre-commit/action@v3.0.1
semgrep:
name: semgrep

View File

@@ -22,4 +22,4 @@ jobs:
pull-requests: write
steps:
- uses: alyf-de/po-review-action@v1.0.0
- uses: alyf-de/po-review-action@v1.1.0

View File

@@ -31,51 +31,49 @@ on:
permissions:
contents: read
packages: read
concurrency:
group: server-mariadb-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: true
# Shared across both jobs. Both run in the SAME CI image so the bench lives at the identical
# path (/home/ci/frappe-bench) on the setup runner and the test shards — that's what makes the
# packaged Python venv portable between them.
env:
TZ: 'Asia/Kolkata'
DEBIAN_FRONTEND: noninteractive
NODE_ENV: "production"
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
ERPNEXT_CI_USER: ci
PIP_CACHE_DIR: /home/ci/.cache/pip
npm_config_cache: /home/ci/.cache/npm
YARN_CACHE_FOLDER: /home/ci/.cache/yarn
UV_CACHE_DIR: /home/ci/.cache/uv
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
TZ: 'Asia/Kolkata'
NODE_ENV: "production"
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
container: [1, 2, 3, 4]
name: Python Unit Tests
services:
mysql:
image: mariadb:10.6
env:
TZ: 'Asia/Kolkata'
MARIADB_ROOT_PASSWORD: 'root'
# Disable durability guarantees that are unnecessary in a throwaway CI container.
# innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win).
# sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer.
MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
# Build the bench (clone + pip + yarn + assets) and reinstall test_site ONCE, on a free
# GitHub-hosted runner, then publish the whole bench (with a DB dump baked in) as an artifact.
# The expensive, non-parallelisable work happens here exactly once instead of on every shard.
setup:
name: Build & reinstall (setup)
# Dedicated scale set (fat cpu request) so the build+reinstall runs at full speed, uncontended
# by the many thin test shards. Same CI image + /home/ci path + 127.0.0.1 DB as the shards,
# so the packaged bench (and its venv) transplants cleanly.
runs-on: erpnext-arc-setup
timeout-minutes: 40
container:
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
credentials:
username: ${{ secrets.GHCR_USERNAME || github.actor }}
password: ${{ secrets.GHCR_TOKEN || github.token }}
defaults:
run:
shell: bash
steps:
- name: Clone
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Check for valid Python & Merge Conflicts
run: |
python -m compileall -fq "${GITHUB_WORKSPACE}"
@@ -84,53 +82,17 @@ jobs:
exit 1
fi
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
check-latest: true
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Cache node modules
uses: actions/cache@v4
# MariaDB runs in-container on a datadir OUTSIDE the bench, because install.sh's next step
# does `rm -rf ~/frappe-bench`. After the reinstall, the datadir is moved into the bench so
# it ships in the artifact — test shards then start an already-loaded server (no restore).
- name: Start DB
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Cache wkhtmltopdf
uses: actions/cache@v4
with:
path: /tmp/wkhtmltox.deb
key: wkhtmltox-0.12.6.1-2-jammy-amd64
SKIP_SYSTEM_SETUP: "1"
CI_DB_DATADIR: /home/ci/db-data
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
@@ -139,9 +101,88 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
DB_HOST: 127.0.0.1
DB_USER_HOST: '%'
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
SKIP_SYSTEM_SETUP: "1"
SKIP_WKHTMLTOX_SETUP: "1"
- name: Warm up test data
run: |
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
cd ~/frappe-bench/
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
EOF
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
- name: Stop DB and stage datadir
run: |
mariadb-admin -h 127.0.0.1 -P 3306 -u root -proot shutdown || true
for _ in $(seq 1 30); do [ -f /home/ci/db-data/mysqld.pid ] || break; sleep 1; done
# Don't bake a dirty datadir — fail if mariadbd didn't finish stopping, rather than ship
# an inconsistent datadir the shards would have to crash-recover.
[ -f /home/ci/db-data/mysqld.pid ] && { echo "mariadbd did not shut down cleanly"; exit 1; }
mv /home/ci/db-data /home/ci/frappe-bench/mariadb-data
# Package the whole bench (apps, venv, node_modules, sites, the DB dump, and hydrate.sh)
# into one artifact for the test shards to consume.
# Single-node hand-off: stage the bench on a node-local hostPath instead of round-tripping
# through GitHub artifact storage (~60s/shard). Setup and shards share the same disk, so
# the shards just untar it locally. NOTE: this assumes one node — a shard on a different
# node could not read this path (then you'd need GitHub artifacts or an NFS/RWX volume).
- name: Stage bench on node (hostPath)
run: |
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/ci/frappe-bench/hydrate.sh
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/ci/frappe-bench/start-db.sh
mkdir -p /opt/ci-bench-staging
# self-clean: drop bench tars from runs older than 2h
find /opt/ci-bench-staging -maxdepth 1 -name '*.tar.gz' -mmin +120 -delete 2>/dev/null || true
# Exclude .git/node_modules; the mariadb-data datadir IS included (the pre-loaded DB).
tar czpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci \
--exclude='.git' --exclude='node_modules' frappe-bench
ls -lh "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz"
# Fan-out: each shard downloads the bench, untars it, starts MariaDB on the baked datadir, and
# runs its slice of the suite. No clone, no build, no reinstall, no DB dump restore on the shards.
test:
name: Python Unit Tests
needs: setup
runs-on: erpnext-arc
timeout-minutes: 60
container:
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
credentials:
username: ${{ secrets.GHCR_USERNAME || github.actor }}
password: ${{ secrets.GHCR_TOKEN || github.token }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
container: [1, 2, 3, 4]
steps:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# Read the bench straight from the node-local hostPath the setup job staged it on — no
# GitHub download. -p preserves the ci (uid 1001) ownership so bench runs as ci cleanly.
- name: Untar bench from node (hostPath)
run: |
tar xzpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci
ls -ld /home/ci/frappe-bench
- name: Hydrate (start DB on baked datadir + bench start)
run: bash /home/ci/frappe-bench/hydrate.sh
env:
DB_HOST: 127.0.0.1
SKIP_SYSTEM_SETUP: "1"
- name: Run Tests
run: |
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
cd ~/frappe-bench/
coverage_flag=""
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
@@ -149,10 +190,10 @@ jobs:
--total-builds ${{ strategy.job-total }} \
--build-number ${{ matrix.container }} \
$coverage_flag
EOF
env:
TYPE: server
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
@@ -162,11 +203,11 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
path: /home/ci/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: test
needs: [test]
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:

View File

@@ -48,7 +48,6 @@ repos:
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
erpnext/public/js/controllers/.*|
erpnext/templates/pages/order.js|
erpnext/templates/includes/.*
)$

View File

@@ -14,35 +14,35 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.3.0",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react": "^6.0.3",
"chrono-node": "^2.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.15.0",
"frappe-react-sdk": "^1.17.0",
"fuse.js": "^7.3.0",
"jotai": "^2.20.0",
"jotai-family": "^1.0.1",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"radix-ui": "^1.6.1",
"react": "^19.2.7",
"react-currency-input-field": "^4.0.5",
"react-day-picker": "9.14.0",
"react-dom": "^19.2.6",
"react-dom": "^19.2.7",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"react-router": "^8.1.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.0",
@@ -51,15 +51,15 @@
"vite": "^8.0.16"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@eslint/js": "^9.39.4",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0"
"typescript-eslint": "^8.62.1"
}
}

View File

@@ -1,5 +1,5 @@
import { lazy, useEffect } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { FrappeProvider } from 'frappe-react-sdk'
import { Toaster } from '@/components/ui/sonner'
import BankReconciliation from '@/pages/BankReconciliation'

View File

@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo, useState } from "react"
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
@@ -26,6 +25,7 @@ import { Form } from "@/components/ui/form"
import { useForm } from "react-hook-form"
import { DateField } from "@/components/ui/form-elements"
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankClearanceSummary = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => {
[accountCurrency, bankAccount, companyID, mutate, onCopy],
)
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -18,6 +18,7 @@ import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Checkbox } from "@/components/ui/checkbox"
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
import { evaluateAmountFormula } from "@/lib/amountFormula"
import { flt, formatCurrency } from "@/lib/numbers"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
@@ -215,38 +216,13 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci
})
} else {
/**
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
* So we need to compute the value of the expression
* We can use the eval function to do this. But we need to expose certain variables to the expression.
* One of them is transaction_amount which is the unallocated amount of the selected transaction
* @param expression - The expression to compute
* @returns The computed value
*/
const computeExpression = (expression: string) => {
const script = `
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
${expression};
`
let value = 0;
try {
value = window.eval(script);
} catch (error: unknown) {
console.error(error);
value = 0;
}
return value;
}
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
if (!acc?.debit && !acc?.credit) {
hasTotallyEmptyRowEarlier = true;
}
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
totalDebits = flt(totalDebits + computedDebit, 2)
totalCredits = flt(totalCredits + computedCredit, 2)

View File

@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import { useCallback, useMemo } from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useFrappeGetCall } from "frappe-react-sdk"
@@ -19,6 +18,7 @@ import _ from "@/lib/translate"
import { toast } from "sonner"
import { useCopyToClipboard } from "usehooks-ts"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankReconciliationStatement = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => {
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
}, [data])
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
}} />
</Paragraph>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -1,7 +1,6 @@
import { useAtomValue, useSetAtom } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { Paragraph } from "@/components/ui/typography"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
@@ -23,6 +22,7 @@ import { useCallback, useMemo, useState } from "react"
import { Link } from "react-router"
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import MarkdownRenderer from "@/components/ui/markdown"
const BankTransactions = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
@@ -243,14 +243,14 @@ const BankTransactionListView = () => {
}, [data, search, amountFilter, typeFilter, status])
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-2 py-2">
<div className="flex gap-2 justify-between items-center">
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
<Button size='md' variant='subtle' asChild>
<Link to="/statement-importer">

View File

@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo } from "react"
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
@@ -18,6 +17,7 @@ import { PartyPopper } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import _ from "@/lib/translate"
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const IncorrectlyClearedEntries = () => {
const companyID = useCurrentCompany()
@@ -177,22 +177,22 @@ const IncorrectlyClearedEntriesView = () => {
[accountCurrency, onClearClick],
)
const content = _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
}} />
<span className="text-p-sm">
<MarkdownRenderer content={content} />
<br />
{data && data.message.result.length > 0 && <span>
<span dangerouslySetInnerHTML={{
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
<MarkdownRenderer content={entriesContent} />
<br />
{_("You can reset the clearing dates of these entries here.")}
</span>}
</Paragraph>
</span>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -11,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { H4, Paragraph } from "@/components/ui/typography"
import { today } from "@/lib/date"
import { evaluateAmountFormula } from "@/lib/amountFormula"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
@@ -445,11 +446,10 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
// If it's a string and cannot be a number, then show it as a formula
if (isNaN(Number(value))) {
let calculatedValue = "";
try {
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
} catch (error: unknown) {
console.error(error);
calculatedValue = "Error";

View File

@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { Link, useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router'
import { useMemo, useState } from 'react'
import { Progress } from '@/components/ui/progress'
import { useSetAtom } from 'jotai'

View File

@@ -0,0 +1,26 @@
import { Parser } from 'safe-expr-eval'
const parser = new Parser()
const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
const trimmed = expression.trim()
if (!trimmed) {
return 0
}
if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
return Number(trimmed)
}
try {
const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
if (typeof result !== 'number' || !Number.isFinite(result)) {
return 0
}
return result
} catch {
return 0
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.24.0"
__version__ = "16.27.0"
def get_default_company(user=None):

View File

@@ -406,8 +406,7 @@
"Customer Deposits": {
"account_number": "2500",
"is_group": 0,
"root_type": "Liability",
"account_type": "Payable"
"root_type": "Liability"
}
},
"Non Current Liabilities": {

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
class OverlapError(frappe.ValidationError):
@@ -36,8 +37,20 @@ class AccountingPeriod(Document):
# end: auto-generated types
def validate(self):
self.validate_dates()
self.validate_overlap()
def validate_dates(self):
if getdate(self.start_date) > getdate(self.end_date):
frappe.throw(_("Start Date cannot be after End Date"))
if getdate(self.end_date) > getdate(nowdate()):
frappe.throw(
_(
"Accounting Period cannot be created for a future date. End Date {0} is after today."
).format(frappe.bold(frappe.format(self.end_date, "Date")))
)
def before_insert(self):
self.bootstrap_doctypes_for_closing()

View File

@@ -3,7 +3,7 @@
import unittest
import frappe
from frappe.utils import add_months, nowdate
from frappe.utils import nowdate
from erpnext.accounts.doctype.accounting_period.accounting_period import (
ClosedAccountingPeriod,
@@ -94,7 +94,7 @@ def create_accounting_period(**args):
accounting_period = frappe.new_doc("Accounting Period")
accounting_period.start_date = args.start_date or nowdate()
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
accounting_period.end_date = args.end_date or nowdate()
accounting_period.company = args.company or "_Test Company"
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})

View File

@@ -86,6 +86,7 @@
"period_closing_settings_section",
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
"pcv_job_timeout",
"column_break_25",
"reports_tab",
"remarks_section",
@@ -611,6 +612,14 @@
"fieldtype": "Check",
"label": "Use legacy controller for Period Closing Voucher"
},
{
"default": "3600",
"depends_on": "eval: !doc.use_legacy_controller_for_pcv",
"description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
"fieldname": "pcv_job_timeout",
"fieldtype": "Int",
"label": "PCV Job Timeout (seconds)"
},
{
"description": "Users with this role will be notified if the asset depreciation gets failed",
"fieldname": "role_to_notify_on_depreciation_failure",
@@ -748,7 +757,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-15 18:26:50.778723",
"modified": "2026-06-24 12:59:41.868865",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -90,6 +90,7 @@ class AccountsSettings(Document):
make_payment_via_journal_entry: DF.Check
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
pcv_job_timeout: DF.Int
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int

View File

@@ -107,7 +107,7 @@ def get_party_bank_account(party_type, party):
)
def get_default_company_bank_account(company, party_type, party):
def get_default_company_bank_account(company, party_type, party, ignore_permissions=True):
default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account")
if default_company_bank_account:
if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"):
@@ -118,6 +118,14 @@ def get_default_company_bank_account(company, party_type, party):
"Bank Account", {"company": company, "is_company_account": 1, "is_default": 1}
)
if not ignore_permissions:
default_company_bank_account = (
default_company_bank_account
if default_company_bank_account
and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select")
else None
)
return default_company_bank_account

View File

@@ -64,7 +64,7 @@ def get_bank_transactions(
filters.append(["date", "<=", to_date])
if from_date:
filters.append(["date", ">=", from_date])
transactions = frappe.get_all(
transactions = frappe.get_list(
"Bank Transaction",
fields=[
"date",
@@ -93,6 +93,7 @@ def get_bank_transactions(
@frappe.whitelist()
def get_account_balance(bank_account: str, till_date: str | date, company: str):
# returns account balance till the specified date
frappe.has_permission("Bank Account", "read", bank_account, throw=True)
account = frappe.db.get_value("Bank Account", bank_account, "account")
filters = frappe._dict(
{

View File

@@ -54,7 +54,6 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Closing Balance",
"non_negative": 1,
"options": "currency"
},
{
@@ -191,7 +190,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-05-08 17:55:25.615942",
"modified": "2026-07-09 17:55:25.615942",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import Log",

View File

@@ -557,7 +557,7 @@ class BankStatementImportLog(Document):
docname=self.name,
)
if self.closing_balance and self.closing_balance > 0 and self.end_date:
if self.closing_balance is not None and self.end_date:
set_closing_balance_as_per_statement(
self.bank_account, frappe.utils.getdate(self.end_date), self.closing_balance
)
@@ -829,7 +829,9 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
if amount_format == 'Amount column has "CR"/"DR" values':
amount = transaction_row.get("amount")
float_amount = get_float_amount(amount)
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
float_amount = abs(get_float_amount(amount) or 0)
if "cr" in amount.lower():
return 0, float_amount
else:
@@ -932,14 +934,18 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
if reader.is_encrypted and (not password or not reader.decrypt(password)):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
if reader.is_encrypted:
# Try opening the PDF with a password - if no password is provided, try with a blank password
if not password:
password = ""
if not reader.decrypt(password):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
tables = []

View File

@@ -104,6 +104,36 @@ class TestBankTransaction(ERPNextTestSuite):
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
# Amending a reconciled payment entry must not carry over its clearance date
def test_clearance_date_cleared_on_amend(self):
bank_transaction = frappe.get_doc(
"Bank Transaction",
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
)
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
vouchers = json.dumps(
[
{
"payment_doctype": "Payment Entry",
"payment_name": payment.name,
"amount": bank_transaction.unallocated_amount,
}
]
)
reconcile_vouchers(bank_transaction.name, vouchers)
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
payment.reload()
payment.cancel()
amended = frappe.copy_doc(payment)
amended.amended_from = payment.name
amended.docstatus = 0
amended.insert()
self.assertFalse(amended.clearance_date)
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc(

View File

@@ -9,6 +9,48 @@ from frappe.model.document import Document
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
PLAIN_NUMBER_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
# Tokens accepted by safe-expr-eval on the frontend (must stay in sync).
ALLOWED_FORMULA_TOKEN = re.compile(r"\s+|transaction_amount|\d+(?:\.\d+)?|[+\-*/%^()]")
PYTHON_ONLY_OPERATORS = ("**", "//")
def _is_expr_eval_formula(formula: str) -> bool:
position = 0
while position < len(formula):
match = ALLOWED_FORMULA_TOKEN.match(formula, position)
if not match:
return False
position = match.end()
return formula.count("(") == formula.count(")")
def validate_amount_formula(formula: str) -> None:
if not formula:
return
stripped = formula.strip()
if PLAIN_NUMBER_PATTERN.match(stripped):
return
if any(operator in stripped for operator in PYTHON_ONLY_OPERATORS):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
if not _is_expr_eval_formula(stripped):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
# expr-eval uses ^ for exponentiation; translate for a smoke-test evaluation only.
python_formula = stripped.replace("^", "**")
try:
result = frappe.safe_eval(python_formula, eval_globals=None, eval_locals={"transaction_amount": 1})
except Exception:
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
if not isinstance(result, (int | float)):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
class BankTransactionRule(Document):
# begin: auto-generated types
@@ -86,6 +128,11 @@ class BankTransactionRule(Document):
frappe.throw(
_("The last account row must not have any debit or credit amounts set.")
)
else:
if account.debit:
validate_amount_formula(account.debit)
if account.credit:
validate_amount_formula(account.credit)
# Validate regex
for rule in self.description_rules:

View File

@@ -231,3 +231,45 @@ class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
doc = self._rule("bad_rx", [{"check": "Regex", "value": "["}])
with self.assertRaises(ValidationError):
doc.insert()
def _multiple_accounts_rule(self, prefix: str, accounts, **fields):
return self._rule(
prefix,
[{"check": "Contains", "value": "x"}],
classify_as="Bank Entry",
bank_entry_type="Multiple Accounts",
accounts=accounts,
**fields,
)
def test_validate_bank_entry_multiple_valid_amount_formulas(self):
doc = self._multiple_accounts_rule(
"be_formula",
accounts=[
{"account": self.bank, "debit": "200", "credit": ""},
{"account": self.cash, "debit": "", "credit": "transaction_amount * 0.25"},
{"account": self.cash, "debit": "", "credit": ""},
],
)
doc.insert()
self.assertTrue(doc.name)
def test_validate_bank_entry_multiple_invalid_amount_formulas(self):
malicious_formulas = [
"__import__('os')",
"eval('1+1')",
"open('/etc/passwd')",
"transaction_amount ** 2",
"transaction_amount // 2",
]
for formula in malicious_formulas:
with self.subTest(formula=formula):
doc = self._multiple_accounts_rule(
"be_bad_formula",
accounts=[
{"account": self.bank, "debit": formula, "credit": ""},
{"account": self.cash, "debit": "", "credit": ""},
],
)
with self.assertRaises(ValidationError):
doc.insert()

View File

@@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
refresh: function (frm) {
if (frm.doc.docstatus == 1) {
frappe.call({
method: "check_journal_entry_condition",
method: "check_journal_and_reversal",
doc: frm.doc,
callback: function (r) {
if (r.message) {
frm.add_custom_button(
__("Journal Entries"),
function () {
return frm.events.make_jv(frm);
},
__("Create")
);
if (!r.message.journals_posted) {
frm.add_custom_button(
__("Journal Entries"),
function () {
return frm.events.make_jv(frm);
},
__("Create")
);
} else if (!r.message.reversals_posted) {
frm.add_custom_button(
__("Reversal Journal Entries"),
function () {
return frm.events.make_reverse_journal(frm);
},
__("Create")
);
}
}
},
});
@@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
},
});
},
make_reverse_journal: function (frm) {
frappe.call({
method: "make_reverse_journal",
doc: frm.doc,
freeze: true,
freeze_message: __("Reversing Journals..."),
});
},
});
frappe.ui.form.on("Exchange Rate Revaluation Account", {

View File

@@ -8,7 +8,7 @@ from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Criterion, Order
from frappe.query_builder.functions import NullIf, Sum
from frappe.utils import flt, get_link_to_form
from frappe.utils import flt, get_link_to_form, nowdate
import erpnext
from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on
@@ -90,25 +90,31 @@ class ExchangeRateRevaluation(Document):
)
def on_cancel(self):
self.ignore_linked_doctypes = "GL Entry"
self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
@frappe.whitelist()
def check_journal_entry_condition(self):
def check_journal_and_reversal(self):
exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
journals_posted = False
reversals_posted = False
je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
journals = (
qb.from_(jea)
.select(jea.parent)
qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.name)
.distinct()
.where(
(jea.reference_type == "Exchange Rate Revaluation")
& (jea.reference_name == self.name)
& (jea.docstatus == 1)
& (je.reversal_of.isnull()) # omit journals that have reversals
)
.run()
.run(pluck="name")
)
if journals:
gle = qb.DocType("GL Entry")
total_amt = (
@@ -123,12 +129,31 @@ class ExchangeRateRevaluation(Document):
.run()
)
if total_amt and total_amt[0][0] != self.total_gain_loss:
return True
if total_amt and total_amt[0][0] == self.total_gain_loss:
journals_posted = True
else:
return False
journals_posted = False
return True
# reverse journals
reverse_journals = (
qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.name)
.where(
(jea.reference_type == "Exchange Rate Revaluation")
& (jea.reference_name == self.name)
& (jea.docstatus == 1)
& (je.reversal_of.notnull())
)
.run(pluck="name")
)
if reverse_journals:
reversals_posted = True
else:
reversals_posted = False
return {"journals_posted": journals_posted, "reversals_posted": reversals_posted}
def fetch_and_calculate_accounts_data(self):
accounts = self.get_accounts_data()
@@ -342,6 +367,7 @@ class ExchangeRateRevaluation(Document):
@frappe.whitelist()
def make_jv_entries(self):
frappe.has_permission("Journal Entry", "write", throw=True)
zero_balance_jv = self.make_jv_for_zero_balance()
if zero_balance_jv:
frappe.msgprint(
@@ -568,6 +594,38 @@ class ExchangeRateRevaluation(Document):
journal_entry.save()
return journal_entry
@frappe.whitelist()
def make_reverse_journal(self):
frappe.has_permission("Journal Entry", "write", throw=True)
je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
journals = (
qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.name)
.distinct()
.where(
(jea.reference_type == "Exchange Rate Revaluation")
& (jea.reference_name == self.name)
& (jea.docstatus == 1)
& (je.reversal_of.isnull()) # omit journals that have reversals
)
.run(pluck="name")
)
if journals:
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.submit()
frappe.msgprint(
_("Revaluation journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
)
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
"""

View File

@@ -132,7 +132,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
err = err.save().submit()
# Create JV for ERR
self.assertTrue(err.check_journal_entry_condition())
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
je = je.submit()
@@ -221,7 +222,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
err = err.save().submit()
# Create JV for ERR
self.assertTrue(err.check_journal_entry_condition())
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
je = je.submit()

View File

@@ -71,7 +71,7 @@ frappe.ui.form.on("Journal Entry", {
refresh: function (frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
frm.set_read_only();
erpnext.journal_entry.lock_reversal_entry(frm);
}
erpnext.toggle_naming_series();
@@ -564,6 +564,13 @@ $.extend(erpnext.journal_entry, {
});
},
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},
set_debit_credit_in_company_currency: function (frm, cdt, cdn) {
var row = locals[cdt][cdn];

View File

@@ -1,7 +1,10 @@
frappe.listview_settings["Journal Entry"] = {
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark"],
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"],
get_indicator: function (doc) {
if (doc.docstatus === 1) {
if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") {
return [__("Reversal Of Exchange Rate Revaluation"), "blue"];
}
return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`];
}
},

View File

@@ -24,15 +24,22 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
setTimeout(
() => {
frm.doc.import_in_progress = false;
frm.clear_table("invoices");
frm.refresh_fields();
frm.page.clear_indicator();
frm.dashboard.hide_progress();
if (frm.doc.invoice_type == "Sales") {
frappe.msgprint(__("Opening Sales Invoices have been created."));
if (!data.errors) {
frm.clear_table("invoices");
frm.refresh_fields();
const message =
frm.doc.invoice_type == "Sales"
? __("Opening Sales Invoice(s) have been created.")
: __("Opening Purchase Invoice(s) have been created.");
frappe.show_alert({
message: message,
indicator: "green",
});
} else {
frappe.msgprint(__("Opening Purchase Invoices have been created."));
frm.refresh_fields();
}
},
1500,

View File

@@ -281,12 +281,20 @@ class OpeningInvoiceCreationTool(Document):
def start_import(invoices):
errors = 0
names = []
total = len(invoices)
for idx, d in enumerate(invoices):
# Scope each invoice to a savepoint so a failure only undoes that invoice.
# A plain rollback() would discard the whole transaction — including invoices
# imported earlier in this batch and the error logs of earlier failures (the
# latter only survive on mariadb because the Error Log table is MyISAM; on
# postgres they would be lost). Rolling back to a savepoint keeps both.
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
frappe.db.savepoint(savepoint)
is_last = idx == total - 1
try:
invoice_number = None
if d.invoice_number:
invoice_number = d.invoice_number
publish(idx, len(invoices), d.doctype)
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
doc.insert(set_name=invoice_number)
@@ -294,10 +302,12 @@ def start_import(invoices):
if not frappe.in_test:
frappe.db.commit()
names.append(doc.name)
publish(idx, total, d.doctype, errors=errors if is_last else None)
except Exception:
errors += 1
frappe.db.rollback()
doc.log_error("Opening invoice creation failed")
publish(idx, total, d.doctype, errors=errors if is_last else None)
if errors:
frappe.msgprint(
_("You had {} errors while creating opening invoices. Check {} for more details").format(
@@ -309,7 +319,7 @@ def start_import(invoices):
return names
def publish(index, total, doctype):
def publish(index, total, doctype, errors=None):
frappe.publish_realtime(
"opening_invoice_creation_progress",
dict(
@@ -317,6 +327,7 @@ def publish(index, total, doctype):
message=_("Creating {} out of {} {}").format(index + 1, total, doctype),
count=index + 1,
total=total,
errors=errors,
),
user=frappe.session.user,
)

View File

@@ -82,6 +82,7 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Outstanding Amount",
"options": "Company:company:default_currency",
"reqd": 1
},
{
@@ -136,7 +137,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-04-29 17:08:15.617047",
"modified": "2026-07-02 15:17:11.938499",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",

View File

@@ -2712,6 +2712,9 @@ def get_party_details(company, party_type, party, date, cost_center=None):
if not frappe.db.exists(party_type, party):
frappe.throw(_("{0} {1} does not exist").format(_(party_type), party))
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
frappe.has_permission(party_type, ptype, party, throw=True)
party_account = get_party_account(party_type, party, company)
account_currency = get_account_currency(party_account)
_party_name = "title" if party_type == "Shareholder" else party_type.lower() + "_name"
@@ -2719,7 +2722,7 @@ def get_party_details(company, party_type, party, date, cost_center=None):
if party_type in ["Customer", "Supplier"]:
party_bank_account = get_party_bank_account(party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False)
return {
"party_account": party_account,

View File

@@ -10,75 +10,22 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestSuite
class TestPaymentLedgerEntry(ERPNextTestSuite):
def setUp(self):
self.ple = qb.DocType("Payment Ledger Entry")
self.create_company()
self.create_item()
self.create_customer()
def create_company(self):
company_name = "_Test Payment Ledger"
company = None
if frappe.db.exists("Company", company_name):
company = frappe.get_doc("Company", company_name)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": company_name,
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
)
company = company.save()
self.company = company.name
self.cost_center = company.cost_center
self.warehouse = "All Warehouses - _PL"
self.income_account = "Sales - _PL"
self.expense_account = "Cost of Goods Sold - _PL"
self.debit_to = "Debtors - _PL"
self.creditors = "Creditors - _PL"
# create bank account
if frappe.db.exists("Account", "HDFC - _PL"):
self.bank = "HDFC - _PL"
else:
bank_acc = frappe.get_doc(
{
"doctype": "Account",
"account_name": "HDFC",
"parent_account": "Bank Accounts - _PL",
"company": self.company,
}
)
bank_acc.save()
self.bank = bank_acc.name
def create_item(self):
item_name = "_Test PL Item"
item = create_item(
item_code=item_name, is_stock_item=0, company=self.company, warehouse=self.warehouse
)
self.item = item if isinstance(item, str) else item.item_code
def create_customer(self):
name = "_Test PL Customer"
if frappe.db.exists("Customer", name):
self.customer = name
else:
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
customer.save()
self.customer = customer.name
self.company = "_Test Company"
self.cost_center = "Main - _TC"
self.warehouse = "Stores - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.debit_to = "Debtors - _TC"
self.creditors = "Creditors - _TC"
self.bank = "Cash - _TC"
self.item = "_Test Item"
self.customer = "_Test Customer"
def create_sales_invoice(
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
@@ -151,18 +98,6 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
)
return so
def clear_old_entries(self):
doctype_list = [
"GL Entry",
"Payment Ledger Entry",
"Sales Invoice",
"Purchase Invoice",
"Payment Entry",
"Journal Entry",
]
for doctype in doctype_list:
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
def create_journal_entry(self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None):
je = frappe.new_doc("Journal Entry")
je.posting_date = posting_date or nowdate()

View File

@@ -6,8 +6,10 @@ import frappe
from frappe import _, msgprint, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
from frappe.query_builder import Case, Criterion
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import IfNull
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
import erpnext
@@ -74,6 +76,10 @@ class PaymentReconciliation(Document):
self.ple_posting_date_filter = []
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
@property
def user_permissions(self):
return get_user_permissions(frappe.session.user)
def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly.
doc_dict = frappe._dict(
@@ -153,6 +159,22 @@ class PaymentReconciliation(Document):
self.add_payment_entries(non_reconciled_payments)
def get_permitted_dimension_values(self, document_type, reference_doctype):
return get_allowed_docs_for_doctype(self.user_permissions.get(document_type, []), reference_doctype)
def validate_permitted_dimension_value(self, document_type, value, allowed):
if value and allowed and value not in allowed:
frappe.throw(
_("You do not have enough permission to access {0}: {1}").format(_(document_type), value),
frappe.PermissionError,
)
def get_user_permission_dimension_condition(self, field, allowed):
value_condition = field.isin(allowed)
if frappe.get_system_settings("apply_strict_user_permissions"):
return value_condition
return (IfNull(field, "") == "") | value_condition
def get_payment_entries(self):
party_account = [self.receivable_payable_account]
@@ -176,8 +198,13 @@ class PaymentReconciliation(Document):
dimensions = {}
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
dimensions.update({dimension: self.get(dimension)})
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Entry")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
dimensions[dimension] = value
elif allowed:
dimensions[dimension] = allowed
condition.update({"accounting_dimensions": dimensions})
payment_entries = get_advance_payment_entries_for_regional(
@@ -201,8 +228,12 @@ class PaymentReconciliation(Document):
# Dimension filters
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
conditions.append(jea[dimension] == self.get(dimension))
allowed = self.get_permitted_dimension_values(x.document_type, "Journal Entry Account")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
conditions.append(jea[dimension] == value)
elif allowed:
conditions.append(self.get_user_permission_dimension_condition(jea[dimension], allowed))
if self.payment_name:
conditions.append(je.name.like(f"%%{self.payment_name}%%"))
@@ -746,8 +777,15 @@ class PaymentReconciliation(Document):
ple = qb.DocType("Payment Ledger Entry")
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
if frappe.db.has_column("Payment Ledger Entry", dimension):
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Ledger Entry")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
self.accounting_dimension_filter_conditions.append(ple[dimension] == value)
elif allowed:
self.accounting_dimension_filter_conditions.append(
self.get_user_permission_dimension_condition(ple[dimension], allowed)
)
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
self.common_filter_conditions.clear()
@@ -796,10 +834,17 @@ class PaymentReconciliation(Document):
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
)
for inv in dr_cr_notes:
if (
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
< inv.allocated_amount
flt(
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
- inv.allocated_amount,
allocated_amount_precision,
)
< 0
):
frappe.throw(
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import qb
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
from frappe.utils.data import getdate as convert_to_date
from erpnext import get_default_cost_center
@@ -51,6 +51,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
sinv = create_sales_invoice(
qty=qty,
rate=rate,
posting_date=posting_date,
company=self.company,
customer=self.customer,
item_code=self.item,
@@ -1105,6 +1106,101 @@ class TestPaymentReconciliation(ERPNextTestSuite):
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertCountEqual(payment_vouchers, [je2.name, pe2.name])
def test_user_permission_on_accounting_dimension_filters_vouchers(self):
test_user = "test@example.com"
permitted_ccs = ["_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"]
restricted_cc = "_Test Write Off Cost Center - _TC"
existing_apply_strict_user_permissions = cint(
frappe.db.get_single_value("System Settings", "apply_strict_user_permissions")
)
self.addCleanup(
frappe.db.set_single_value,
"System Settings",
"apply_strict_user_permissions",
existing_apply_strict_user_permissions,
)
transaction_date = nowdate()
rate = 100
def make_invoice(cost_center):
si = self.create_sales_invoice(
qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True
)
si.cost_center = cost_center
for row in si.items:
row.cost_center = cost_center
return si.submit()
def make_payment(cost_center):
pe = self.create_payment_entry(posting_date=transaction_date, amount=rate)
pe.cost_center = cost_center
return pe.save().submit()
def make_journal(cost_center):
je = self.create_journal_entry(
self.bank, self.debit_to, 100, transaction_date, cost_center=cost_center
)
je.accounts[1].party_type = "Customer"
je.accounts[1].party = self.customer
return je.save().submit()
# Vouchers tagged with the two permitted cost centers
si_allowed = make_invoice(permitted_ccs[0])
pe_allowed = make_payment(permitted_ccs[1])
je_allowed = make_journal(permitted_ccs[0])
# Vouchers tagged with the restricted cost center
si_restricted = make_invoice(restricted_cc)
pe_restricted = make_payment(restricted_cc)
je_restricted = make_journal(restricted_cc)
# Payment entry with a BLANK cost center
pe_blank = make_payment(None)
for cc in permitted_ccs:
frappe.permissions.add_user_permission("Cost Center", cc, test_user)
# Without strict user permissions
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 0)
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertIn(si_allowed.name, invoice_numbers)
self.assertIn(pe_allowed.name, payment_vouchers)
self.assertIn(je_allowed.name, payment_vouchers)
self.assertIn(pe_blank.name, payment_vouchers)
self.assertNotIn(si_restricted.name, invoice_numbers)
self.assertNotIn(pe_restricted.name, payment_vouchers)
self.assertNotIn(je_restricted.name, payment_vouchers)
# With strict user permissions
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 1)
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertIn(si_allowed.name, invoice_numbers)
self.assertIn(pe_allowed.name, payment_vouchers)
self.assertIn(je_allowed.name, payment_vouchers)
self.assertNotIn(pe_blank.name, payment_vouchers)
self.assertNotIn(si_restricted.name, invoice_numbers)
self.assertNotIn(pe_restricted.name, payment_vouchers)
self.assertNotIn(je_restricted.name, payment_vouchers)
# with restricted dimension as a filter
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.cost_center = restricted_cc
self.assertRaises(frappe.PermissionError, pr.get_unreconciled_entries)
for cc in permitted_ccs:
frappe.permissions.remove_user_permission("Cost Center", cc, test_user)
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{
@@ -2018,7 +2114,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
pr.reconcile()
si.reload()
self.assertEqual(si.status, "Partly Paid")
self.assertEqual(si.status, "Overdue")
# check PR tool output post reconciliation
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
@@ -2414,6 +2510,76 @@ class TestPaymentReconciliation(ERPNextTestSuite):
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
pr.reconcile()
def test_cr_note_split_across_invoices_floating_point_precision(self):
"""Regression: when a credit note is split across multiple invoices, floating-point
arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail.
The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0),
so the invoices are created with round-number totals (100, 200, 100) and then partially paid
down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries.
"""
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
# Create invoices on different posting dates to control sort-order in Payment Reconciliation
# (invoices are sorted by posting_date ascending, so si_a is processed first).
# Processing order 8.45 → 90.72 → 72.57 produces the float chain:
# 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001
# The last allocation row will therefore carry allocated_amount = 50.83000000000001.
si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2))
si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1))
si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate())
# Partially pay each invoice so the remaining outstanding is a clean decimal value.
# INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings
# by subtracting a decimal-valued payment from the integer total:
# 100 - 91.55 = 8.45
# 200 - 109.28 = 90.72
# 100 - 27.43 = 72.57
for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)):
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = partial_paid
pe.received_amount = partial_paid
pe.references[0].allocated_amount = partial_paid
pe.save().submit()
cr_note = self.create_sales_invoice(
qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
cr_note.is_return = 1
cr_note = cr_note.save().submit()
pr = self.create_payment_reconciliation()
# Widen date range so all three invoices (oldest is -2 days) are fetched
pr.from_invoice_date = add_days(nowdate(), -2)
pr.to_invoice_date = nowdate()
pr.from_payment_date = nowdate()
pr.to_payment_date = nowdate()
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 3)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c
self.assertEqual(len(pr.allocation), 3)
last_row = pr.allocation[-1]
# Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic)
self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2)
# reconcile() must not raise "has been modified after you pulled it" due to float imprecision
pr.reconcile()
si_a.reload()
si_b.reload()
si_c.reload()
self.assertEqual(si_a.outstanding_amount, 0)
self.assertEqual(si_b.outstanding_amount, 0)
# si_c is only partially settled: 72.57 - 50.83 = 21.74
self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2)
def create_fiscal_year(company, year_start_date, year_end_date):
fy_docname = frappe.db.exists(

View File

@@ -379,6 +379,7 @@ class PaymentRequest(Document):
bank_amount=bank_amount,
created_from_payment_request=True,
)
payment_entry.set_missing_ref_details(force=True)
payment_entry.update(
{
@@ -957,6 +958,7 @@ def resend_payment_email(docname):
@frappe.whitelist()
def make_payment_entry(docname):
doc = frappe.get_doc("Payment Request", docname)
doc.check_permission("read")
return doc.create_payment_entry(submit=False).as_dict()

View File

@@ -775,6 +775,22 @@ class TestPaymentRequest(ERPNextTestSuite):
pi.load_from_db()
self.assertEqual(pr_2.grand_total, pi.outstanding_amount)
def test_payment_entry_reference_details_fetched_from_invoice(self):
pi = make_purchase_invoice(currency="INR", qty=1, rate=94500)
pi.submit()
pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1)
pr.grand_total = 94000
pr.submit()
pe = pr.create_payment_entry(submit=False)
self.assertEqual(pe.references[0].reference_name, pi.name)
self.assertEqual(pe.references[0].total_amount, pi.grand_total)
self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount)
self.assertEqual(pe.references[0].allocated_amount, 94000)
self.assertEqual(pe.paid_amount, 94000)
def test_consider_journal_entry_and_return_invoice(self):
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry

View File

@@ -19,7 +19,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
def test_closing_entry(self):
company = create_company()
cost_center = create_cost_center("Test Cost Center 1")
jv1 = make_journal_entry(
@@ -28,10 +27,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
company="Test PCV Company",
save=False,
)
jv1.company = company
jv1.company = "Test PCV Company"
jv1.save()
jv1.submit()
@@ -41,10 +40,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cost of Goods Sold - TPC",
account2="Cash - TPC",
cost_center=cost_center,
company=company,
company="Test PCV Company",
save=False,
)
jv2.company = company
jv2.company = "Test PCV Company"
jv2.save()
jv2.submit()
@@ -68,14 +67,13 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertEqual(pcv_gle, expected_gle)
def test_cost_center_wise_posting(self):
company = create_company()
surplus_account = create_account()
cost_center1 = create_cost_center("Main")
cost_center2 = create_cost_center("Western Branch")
create_sales_invoice(
company=company,
company="Test PCV Company",
cost_center=cost_center1,
income_account="Sales - TPC",
expense_account="Cost of Goods Sold - TPC",
@@ -86,7 +84,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
posting_date="2021-03-15",
)
create_sales_invoice(
company=company,
company="Test PCV Company",
cost_center=cost_center2,
income_account="Sales - TPC",
expense_account="Cost of Goods Sold - TPC",
@@ -131,12 +129,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
)
def test_period_closing_with_finance_book_entries(self):
company = create_company()
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")
create_sales_invoice(
company=company,
company="Test PCV Company",
income_account="Sales - TPC",
expense_account="Cost of Goods Sold - TPC",
cost_center=cost_center,
@@ -153,9 +150,9 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
amount=400,
cost_center=cost_center,
posting_date="2021-03-15",
company=company,
company="Test PCV Company",
)
jv.company = company
jv.company = "Test PCV Company"
jv.finance_book = create_finance_book().name
jv.save()
jv.submit()
@@ -182,7 +179,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertSequenceEqual(pcv_gle, expected_gle)
def test_gl_entries_restrictions(self):
company = create_company()
cost_center = create_cost_center("Test Cost Center 1")
self.make_period_closing_voucher(posting_date="2021-03-31")
@@ -193,16 +189,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
company="Test PCV Company",
save=False,
)
jv1.company = company
jv1.company = "Test PCV Company"
jv1.save()
self.assertRaises(frappe.ValidationError, jv1.submit)
def test_closing_balance_with_dimensions_and_test_reposting_entry(self):
company = create_company()
cost_center1 = create_cost_center("Test Cost Center 1")
cost_center2 = create_cost_center("Test Cost Center 2")
@@ -212,10 +207,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center1,
company=company,
company="Test PCV Company",
save=False,
)
jv1.company = company
jv1.company = "Test PCV Company"
jv1.save()
jv1.submit()
@@ -225,10 +220,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center2,
company=company,
company="Test PCV Company",
save=False,
)
jv2.company = company
jv2.company = "Test PCV Company"
jv2.save()
jv2.submit()
@@ -255,11 +250,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center2,
company=company,
company="Test PCV Company",
save=False,
)
jv3.company = company
jv3.company = "Test PCV Company"
jv3.save()
jv3.submit()
@@ -294,12 +289,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertEqual(cc2_closing_balance.credit, 500)
self.assertEqual(cc2_closing_balance.credit_in_account_currency, 500)
warehouse = frappe.db.get_value("Warehouse", {"company": company}, "name")
warehouse = frappe.db.get_value("Warehouse", {"company": "Test PCV Company"}, "name")
repost_doc = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"company": company,
"company": "Test PCV Company",
"posting_date": "2020-03-15",
"based_on": "Item and Warehouse",
"item_code": "Test Item 1",
@@ -340,7 +335,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
{"enable_immutable_ledger": 1},
)
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
company = create_company()
cost_center = create_cost_center("Test Cost Center 1")
jv = make_journal_entry(
@@ -349,21 +343,24 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
company="Test PCV Company",
save=False,
)
jv.company = company
jv.company = "Test PCV Company"
jv.save()
jv.submit()
self.make_period_closing_voucher(posting_date="2021-03-31")
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", "2021-12-31")
try:
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
)
finally:
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", None)
totals_after_cancel = frappe.db.sql(
"""
@@ -378,19 +375,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
def create_company():
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": "Test PCV Company",
"country": "United States",
"default_currency": "USD",
}
)
company.insert(ignore_if_duplicate=True)
return company.name
def create_account():
account = frappe.get_doc(
{

View File

@@ -156,6 +156,24 @@ class PricingRule(Document):
if len(values) != len(set(values)):
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
if self.apply_on == "Item Code":
self.validate_template_with_variant(values)
def validate_template_with_variant(self, item_codes):
# throws if a template and its variant both exist in one rule
variants = frappe.get_all(
"Item",
filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)},
fields=["name", "variant_of"],
)
if variants:
variant = variants[0]
frappe.throw(
_("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format(
frappe.bold(variant.name), frappe.bold(variant.variant_of)
)
)
def validate_mandatory(self):
if self.has_priority and not self.priority:
throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority"))

View File

@@ -333,6 +333,31 @@ class TestPricingRule(ERPNextTestSuite):
details = get_item_details(args)
self.assertEqual(details.get("discount_percentage"), 17.5)
def test_pricing_rule_with_template_and_its_variant(self):
if not frappe.db.exists("Item", "Test Variant PRT"):
variant = frappe.new_doc("Item")
variant.item_code = "Test Variant PRT"
variant.item_name = "Test Variant PRT"
variant.item_group = "_Test Item Group"
variant.is_stock_item = 1
variant.variant_of = "_Test Variant Item"
variant.stock_uom = "_Test UOM"
variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"})
variant.insert()
rule = frappe.new_doc("Pricing Rule")
rule.title = "_Test Pricing Rule Template Variant"
rule.apply_on = "Item Code"
rule.currency = "USD"
rule.selling = 1
rule.rate_or_discount = "Discount Percentage"
rule.discount_percentage = 10
rule.company = "_Test Company"
rule.append("items", {"item_code": "_Test Variant Item"})
rule.append("items", {"item_code": "Test Variant PRT"})
self.assertRaises(frappe.ValidationError, rule.insert)
def test_pricing_rule_for_stock_qty(self):
test_record = {
"doctype": "Pricing Rule",

View File

@@ -89,48 +89,55 @@ class ProcessPeriodClosingVoucher(Document):
cancel_pcv_processing(self.name)
def initialize_parallel_threads(docname: str):
threads = 4
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(threads)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
x.name,
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=x.name,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@frappe.whitelist()
def start_pcv_processing(docname: str):
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(4)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": x.processing_date,
"parent": docname,
"report_type": x.report_type,
"parentfield": x.parentfield,
},
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout="3600",
is_async=True,
enqueue_after_commit=True,
docname=docname,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
initialize_parallel_threads(docname)
@frappe.whitelist()
@@ -247,10 +254,12 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if to_process := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(1)
@@ -260,22 +269,23 @@ def schedule_next_date(docname: str):
if not is_scheduler_inactive():
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": to_process[0].processing_date,
"parent": docname,
"report_type": to_process[0].report_type,
"parentfield": to_process[0].parentfield,
},
to_process[0].name,
"status",
"Running",
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout="3600",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=to_process[0].name,
date=to_process[0].processing_date,
report_type=to_process[0].report_type,
parentfield=to_process[0].parentfield,
@@ -302,7 +312,7 @@ def schedule_next_date(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
queue="long",
timeout="3600",
timeout=timeout,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
@@ -440,6 +450,11 @@ def summarize_and_post_ledger_entries(docname):
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@@ -525,10 +540,10 @@ def build_dimension_wise_balance_dict(gl_entries):
return dimension_balances
def process_individual_date(docname: str, date, report_type, parentfield):
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
current_date_status = frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
row_name,
"status",
)
if current_date_status != "Running":
@@ -575,17 +590,20 @@ def process_individual_date(docname: str, date, report_type, parentfield):
# save results
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
row_name,
"closing_balance",
frappe.json.dumps(res),
)
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
row_name,
"status",
"Completed",
)
# commit heavy computation before touching PPCV or PPCVD
if not frappe.in_test:
frappe.db.commit() # nosemgrep
# chain call
schedule_next_date(docname)

View File

@@ -48,18 +48,27 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
ppcv.save()
return ppcv
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
def set_processing_date_status(self, row_name, status):
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
row_name,
"status",
status,
)
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
def get_row_name(self, ppcv_name, rpt_type, parentfield):
return frappe.db.get_all(
"Process Period Closing Voucher Detail",
filters={"parent": ppcv_name, "report_type": rpt_type, "parentfield": parentfield},
order_by="report_type, idx",
pluck="name",
limit=1,
)[0]
def get_processing_date_closing_balance(self, row_name):
return frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
row_name,
"closing_balance",
)
@@ -97,11 +106,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
parentfield = "normal_balances"
rpt_type = "Profit and Loss"
# status has to be set to 'Running' for logic to run
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.assertEqual(len(bal), 1)
expected_pl = {
"account": "Sales - _TC",
@@ -117,11 +125,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Balance sheet balance
rpt_type = "Balance Sheet"
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.assertEqual(len(bal), 1)
expected_bs = {
"account": "Debtors - _TC",
@@ -138,11 +145,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Opening balance
parentfield = "z_opening_balances"
rpt_type = "Balance Sheet"
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.assertEqual(len(bal), 2)
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
expected_opening_cash = {

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe.model.document import Document
@@ -24,3 +24,10 @@ class ProcessPeriodClosingVoucherDetail(Document):
# end: auto-generated types
pass
def on_doctype_update():
frappe.db.add_index(
"Process Period Closing Voucher Detail",
["parent", "status", "parentfield", "idx", "processing_date"],
)

View File

@@ -13,7 +13,7 @@
</div>
{% endif %}
</div>
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
<div>
{% if filters.party[0] == filters.party_name[0] %}
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>

View File

@@ -416,7 +416,6 @@ def get_context(customer, doc):
return {
"doc": template_doc,
"customer": frappe.get_doc("Customer", customer),
"frappe": frappe.utils,
}
@@ -466,6 +465,8 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
when Is Billing Contact checked
and Primary email- email with Is Primary checked"""
frappe.has_permission("Customer", "read", customer_name, throw=True)
billing_email = frappe.db.sql(
"""
SELECT
@@ -509,6 +510,7 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
@frappe.whitelist()
def download_statements(document_name):
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
doc.check_permission("read")
report = get_report_pdf(doc)
if report:
frappe.local.response.filename = doc.name + ".pdf"

View File

@@ -1388,8 +1388,10 @@
"fetch_from": "supplier.represents_company",
"fieldname": "represents_company",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Represents Company",
"options": "Company"
"options": "Company",
"read_only": 1
},
{
"depends_on": "eval:doc.update_stock && doc.is_internal_supplier",
@@ -1700,7 +1702,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-06-13 18:36:46.704623",
"modified": "2026-07-12 23:54:21.263951",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -2928,6 +2928,24 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
self.assertRaises(frappe.ValidationError, pi.submit)
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_po_is_blocked(self):
service_item = create_item(
"_Test Service Item Non Stock PI",
is_stock_item=0,
is_purchase_item=1,
).name
po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
po.submit()
pi = make_pi_from_po(po.name)
pi.items[0].qty = 10 # overbill by 100 %
pi.save()
with self.assertRaises(frappe.ValidationError):
pi.submit()
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
pi = make_purchase_invoice(do_not_save=True)
discount_amount = 7

View File

@@ -2150,11 +2150,14 @@ class TestSalesInvoice(ERPNextTestSuite):
def test_create_so_with_margin(self):
si = create_sales_invoice(item_code="_Test Item", qty=1, do_not_submit=True)
price_list_rate = flt(100) * flt(si.plc_conversion_rate)
si.items[0].price_list_rate = price_list_rate
si.items[0].margin_type = "Percentage"
si.items[0].margin_rate_or_amount = 25
si.items[0].discount_amount = 0.0
si.items[0].discount_percentage = 0.0
# set rate to zero, so that it is recalculated on save
si.items[0].rate = 0
si.save()
self.assertEqual(si.get("items")[0].rate, flt((price_list_rate * 25) / 100 + price_list_rate))
@@ -3865,6 +3868,51 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertTrue("cannot overbill" in str(err.exception).lower())
dn.cancel()
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_so_is_blocked(self):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
service_item = create_item(
"_Test Service Item Non Stock SI",
is_stock_item=0,
).name
so = make_sales_order(item_code=service_item, qty=5, rate=100)
so.submit()
si = make_si_from_so(so.name)
si.items[0].qty = 10 # overbill by 100 %
si.save()
with self.assertRaises(frappe.ValidationError):
si.submit()
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
service_item = create_item(
"_Test Service Item Non Stock SI Quot",
is_stock_item=0,
).name
quotation = make_quotation(item_code=service_item, qty=5, rate=100)
so = make_so_from_quotation(quotation.name)
so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
so.insert()
so.submit()
si = make_si_from_so(so.name)
si.items[0].qty = 10 # overbill by 100 %
si.save()
with self.assertRaises(frappe.ValidationError):
si.submit()
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{

View File

@@ -716,13 +716,15 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -789,7 +791,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date

View File

@@ -0,0 +1,26 @@
{
"align": "Left",
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
"creation": "2026-05-15 15:21:48.255627",
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
"disabled": 0,
"docstatus": 0,
"doctype": "Letter Head",
"footer_align": "Left",
"footer_image_height": 0.0,
"footer_image_width": 0.0,
"footer_source": "Image",
"idx": 0,
"image_height": 0.0,
"image_width": 0.0,
"is_default": 0,
"letter_head_for": "DocType",
"letter_head_name": "Company Letterhead",
"modified": "2026-06-24 17:49:52.350750",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead",
"owner": "Administrator",
"source": "HTML",
"standard": "Yes"
}

View File

@@ -0,0 +1,26 @@
{
"align": "Left",
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
"creation": "2026-05-15 15:21:48.373815",
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
"disabled": 0,
"docstatus": 0,
"doctype": "Letter Head",
"footer_align": "Left",
"footer_image_height": 0.0,
"footer_image_width": 0.0,
"footer_source": "Image",
"idx": 0,
"image_height": 0.0,
"image_width": 0.0,
"is_default": 0,
"letter_head_for": "DocType",
"letter_head_name": "Company Letterhead - Grey",
"modified": "2026-06-24 18:23:05.120521",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead - Grey",
"owner": "Administrator",
"source": "HTML",
"standard": "Yes"
}

View File

@@ -0,0 +1,26 @@
{
"align": "Left",
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:top\">\n\t\t\t\t{% if doc.company %}{% set company = frappe.get_doc(\"Company\", doc.company) %}{% else %}{% set company = frappe._dict() %}{% endif %}\n\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company.company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\" style=\"vertical-align:top\">\n\t\t\t\t{% if company.name %}<div class=\"company-name\">{{ company.name }}</div>{% endif %}\n\n\t\t\t\t{% set company_address_name = frappe.db.get_value(\n\t\t\t\t\t\"Dynamic Link\",\n\t\t\t\t\t{\n\t\t\t\t\t\t\"link_doctype\": \"Company\",\n\t\t\t\t\t\t\"link_name\": company.name,\n\t\t\t\t\t\t\"parenttype\": \"Address\"\n\t\t\t\t\t},\n\t\t\t\t\t\"parent\"\n\t\t\t\t) %}\n\n\t\t\t\t{% if company_address_name %}\n\t\t\t\t\t{% set company_address = frappe.db.get_value(\n\t\t\t\t\t\t\"Address\",\n\t\t\t\t\t\tcompany_address_name,\n\t\t\t\t\t\t[\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"],\n\t\t\t\t\t\tas_dict=True\n\t\t\t\t\t) %}\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if company_address %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{{ company_address.address_line1 or \"\" }}\n\n\t\t\t\t\t{% if company_address.address_line2 %}\n\t\t\t\t\t\t<br>{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t<br>\n\n\t\t\t\t\t{{ company_address.city or \"\" }}\n\t\t\t\t\t{% if company_address.state %}, {{ company_address.state }}{% endif %}\n\t\t\t\t\t{{ company_address.pincode or \"\" }}\n\n\t\t\t\t\t{% if company_address.country %}\n\t\t\t\t\t\t, {{ company_address.country }}\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\" style=\"vertical-align:top;text-align:right\">\n\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %}\n\t\t\t\t{% set email = frappe.db.get_value(\"Company\", doc.company, \"email\") %}\n\t\t\t\t{% set phone_no = frappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t</tr>\n\t</tbody>\n</table>",
"creation": "2026-05-15 19:49:47.582252",
"custom_css": ".letter-head {\n\tborder-radius: 18px;\n\tpadding: 8px 10px;\n\tmargin: 10px 0 14px;\n\tfont-family: Inter, sans-serif;\n\tfont-size: 14px;\n\tcolor: #171717;\n}\n\n.letter-head td {\n\tpadding: 0 !important;\n\tvertical-align: middle;\n}\n\n.invoice-header {\n\twidth: 100%;\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\tborder-bottom: 1px solid #ededed;\n\tpadding-bottom: 10px;\n}\n\n.logo-cell {\n\twidth: 100px;\n\ttext-align: center;\n\twhite-space: nowrap;\n}\n\n.logo-container {\n\tdisplay: inline-block;\n\tmargin: auto;\n}\n\n.logo-container img {\n\tmax-width: 95px;\n\tmax-height: 95px;\n\tdisplay: block;\n\tborder-radius: 12px;\n}\n\n.company-details {\n\twidth: 55%;\n\tpadding-left: 10px !important;\n\tline-height: 1.5;\n}\n\n.company-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 4px;\n}\n\n.company-address {\n\tfont-size: 14px;\n\tline-height: 1.5;\n\tcolor: #171717;\n}\n\n.invoice-info-cell {\n\twidth: 240px;\n\ttext-align: right;\n\tvertical-align: top !important;\n\tline-height: 1.5;\n}\n\n.document-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 6px;\n}\n\n.invoice-info {\n\tfont-size: 14px;\n\tcolor: #171717;\n\tmargin-bottom: 2px;\n\tfont-variant-numeric: tabular-nums;\n}\n\n.invoice-label {\n\tcolor: #7c7c7c;\n\tfont-weight: 500;\n\tmargin-right: 4px;\n\tdisplay: inline-block;\n}",
"disabled": 0,
"docstatus": 0,
"doctype": "Letter Head",
"footer_align": "Left",
"footer_image_height": 0.0,
"footer_image_width": 0.0,
"footer_source": "Image",
"idx": 0,
"image_height": 0.0,
"image_width": 0.0,
"is_default": 0,
"letter_head_for": "Report",
"letter_head_name": "Company Letterhead Report",
"modified": "2026-06-24 18:06:39.820968",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead Report",
"owner": "Administrator",
"source": "HTML",
"standard": "Yes"
}

View File

@@ -428,6 +428,17 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
Will first search in party (Customer / Supplier) record, if not found,
will search in group (Customer Group / Supplier Group),
finally will return default."""
def account_perm_check(account):
ptype = "select" if frappe.only_has_select_perm("Account") else "read"
if frappe.has_permission("Account", ptype, account):
return
# Using custom message to prevent data leak in case of `apply_strict_permission` is enabled.
frappe.throw(
_("User don't have permissions to select/read this account."), exc=frappe.PermissionError
)
if not party_type:
frappe.throw(_("Party Type is mandatory"))
if not company:
@@ -438,46 +449,51 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
return frappe.get_cached_value("Company", company, default_account_name)
account = frappe.db.get_value(
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
)
if not account and party_type in ["Customer", "Supplier"]:
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
account = frappe.get_cached_value("Company", company, default_account_name)
else:
account = frappe.db.get_value(
"Party Account",
{"parenttype": party_group_doctype, "parent": group, "company": company},
"account",
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
)
if not account and party_type in ["Customer", "Supplier"]:
default_account_name = (
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
account = frappe.get_cached_value("Company", company, default_account_name)
if not account and party_type in ["Customer", "Supplier"]:
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
account = frappe.db.get_value(
"Party Account",
{"parenttype": party_group_doctype, "parent": group, "company": company},
"account",
)
existing_gle_currency = get_party_gle_currency(party_type, party, company)
if existing_gle_currency:
if account:
account_currency = frappe.get_cached_value("Account", account, "account_currency")
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
if not account and party_type in ["Customer", "Supplier"]:
default_account_name = (
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
account = frappe.get_cached_value("Company", company, default_account_name)
# get default account on the basis of party type
if not account:
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
default_account_name = "default_" + account_type.lower() + "_account"
account = frappe.get_cached_value("Company", company, default_account_name)
existing_gle_currency = get_party_gle_currency(party_type, party, company)
if existing_gle_currency:
if account:
account_currency = frappe.get_cached_value("Account", account, "account_currency")
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
if include_advance and party_type in ["Customer", "Supplier", "Student"]:
# get default account on the basis of party type
if not account:
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
default_account_name = "default_" + account_type.lower() + "_account"
account = frappe.get_cached_value("Company", company, default_account_name)
if account:
account_perm_check(account)
if include_advance and party and party_type in ["Customer", "Supplier", "Student"]:
advance_account = get_party_advance_account(party_type, party, company)
if advance_account:
account_perm_check(advance_account)
return [account, advance_account]
else:
return [account]
return [account]
return account
@@ -927,6 +943,15 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
if party_type == "Supplier":
info["total_unpaid"] = -1 * info["total_unpaid"]
if info["total_unpaid"] < 0:
info["balance_label"] = (
"Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
)
info["balance_amount"] = abs(info["total_unpaid"])
else:
info["balance_label"] = "Total Unpaid"
info["balance_amount"] = info["total_unpaid"]
company_wise_info.append(info)
return company_wise_info

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -265,10 +265,12 @@ class ReceivablePayableReport:
# Build and use a separate row for Employee Advances.
# This allows Payments or Journals made against Emp Advance to be processed.
if (
not row
and ple.against_voucher_type == "Employee Advance"
and self.filters.handle_employee_advances
if not row and (
(ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances)
or (
ple.against_voucher_type == "Exchange Rate Revaluation"
and self.filters.for_revaluation_journals
)
):
_d = self.build_voucher_dict(ple)
_d.voucher_type = ple.against_voucher_type

View File

@@ -76,6 +76,7 @@ def get_ratios_data(filters, period_list, years):
cogs, total_expense = {}, {}
quick_asset = {}
direct_expense = {}
fixed_asset = {}
for year in years:
total_quick_asset = 0
@@ -93,6 +94,7 @@ def get_ratios_data(filters, period_list, years):
quick_asset,
total_quick_asset,
],
[fixed_asset, total_asset, "Fixed Asset", year, assets, "Asset", {}, 0],
[
current_liability,
total_liability,
@@ -112,7 +114,7 @@ def get_ratios_data(filters, period_list, years):
add_solvency_ratios(
data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense
)
add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense)
add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense)
return data
@@ -193,7 +195,7 @@ def add_solvency_ratios(
data.append(return_on_equity_ratio)
def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense):
def add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense):
precision = frappe.db.get_single_value("System Settings", "float_precision")
data.append({"ratio": _("Turnover Ratios")})
@@ -208,7 +210,7 @@ def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sale
)
ratio_data = [
[_("Fixed Asset Turnover Ratio"), net_sales, total_asset],
[_("Fixed Asset Turnover Ratio"), net_sales, fixed_asset],
[_("Debtor Turnover Ratio"), net_sales, avg_debtors],
[_("Creditor Turnover Ratio"), direct_expense, avg_creditors],
[_("Inventory Turnover Ratio"), cogs, avg_stock],

View File

@@ -0,0 +1,73 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.report.financial_ratios.financial_ratios import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestFinancialRatios(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
self.abbr = "_TC"
# The report matches the group accounts by their account_type, which the
# standard chart of accounts does not set on group accounts by default.
self.set_account_type("Fixed Assets", "Fixed Asset")
self.set_account_type("Direct Income", "Direct Income")
def set_account_type(self, account_name, account_type):
frappe.db.set_value("Account", f"{account_name} - {self.abbr}", "account_type", account_type)
def test_fixed_asset_turnover_uses_net_fixed_assets(self):
# Acquire a fixed asset worth 10,000 funded by equity.
self.make_journal_entry("Buildings", "Capital Stock", 10000)
# Book sales of 20,000 collected in cash. Total assets now = 30,000
# (Buildings 10,000 + Cash 20,000), while net fixed assets stay at 10,000.
self.make_journal_entry("Cash", "Sales", 20000)
columns, data = execute(self.get_report_filters())
year_key = columns[1]["fieldname"]
ratio_row = next((row for row in data if row.get("ratio") == "Fixed Asset Turnover Ratio"), None)
self.assertIsNotNone(ratio_row, "Fixed Asset Turnover Ratio row not found in report output")
# Net Sales / Net Fixed Assets = 20,000 / 10,000 = 2.0
# (the old behaviour divided by total assets, giving 20,000 / 30,000 = 0.667)
self.assertEqual(ratio_row[year_key], 2.0)
def get_report_filters(self):
active_fy = frappe.db.get_value(
"Fiscal Year",
{"disabled": 0, "year_start_date": ("<=", today()), "year_end_date": (">=", today())},
["name", "year_start_date", "year_end_date"],
as_dict=True,
)
return frappe._dict(
company=self.company,
from_fiscal_year=active_fy.name,
to_fiscal_year=active_fy.name,
period_start_date=active_fy.year_start_date,
period_end_date=active_fy.year_end_date,
filter_based_on="Fiscal Year",
periodicity="Yearly",
)
def make_journal_entry(self, debit_account, credit_account, amount):
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.posting_date = today()
journal_entry.company = self.company
for account, debit, credit in (
(debit_account, amount, 0),
(credit_account, 0, amount),
):
journal_entry.append(
"accounts",
{
"account": f"{account} - {self.abbr}",
"debit_in_account_currency": debit,
"credit_in_account_currency": credit,
},
)
journal_entry.insert()
journal_entry.submit()

View File

@@ -562,7 +562,12 @@ class GrossProfitGenerator:
row.base_amount = packed_item.base_amount
# get buying amount
if row.item_code in product_bundles:
if row.is_debit_note:
# Rate adjustment debit notes have no stock movement, so buying amount is zero
if not grouped_by_invoice:
row.qty = 0
row.buying_amount = 0
elif row.item_code in product_bundles:
row.buying_amount = flt(
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
self.currency_precision,
@@ -951,6 +956,7 @@ class GrossProfitGenerator:
SalesInvoice.customer_group,
SalesInvoice.customer_name,
SalesInvoice.territory,
SalesInvoice.is_debit_note,
SalesInvoiceItem.item_code,
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
SalesInvoiceItem.item_name,
@@ -1131,6 +1137,7 @@ class GrossProfitGenerator:
"posting_time": row.posting_time,
"project": row.project,
"update_stock": row.update_stock,
"is_debit_note": row.is_debit_note,
"customer": row.customer,
"customer_group": row.customer_group,
"customer_name": row.customer_name,
@@ -1169,6 +1176,7 @@ class GrossProfitGenerator:
"description": item.description,
"warehouse": item.warehouse or row.warehouse,
"update_stock": row.update_stock,
"is_debit_note": row.is_debit_note,
"item_group": "",
"brand": "",
"dn_detail": row.dn_detail,

View File

@@ -14,71 +14,17 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestGrossProfit(ERPNextTestSuite):
def setUp(self):
self.create_company()
self.create_item()
self.create_bundle()
self.create_customer()
def create_company(self):
company_name = "_Test Gross Profit"
abbr = "_GP"
if frappe.db.exists("Company", company_name):
company = frappe.get_doc("Company", company_name)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": company_name,
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
)
company = company.save()
self.company = company.name
self.cost_center = company.cost_center
self.warehouse = "Stores - " + abbr
self.finished_warehouse = "Finished Goods - " + abbr
self.income_account = "Sales - " + abbr
self.expense_account = "Cost of Goods Sold - " + abbr
self.debit_to = "Debtors - " + abbr
self.creditors = "Creditors - " + abbr
def create_item(self):
item = create_item(
item_code="_Test GP Item", is_stock_item=1, company=self.company, warehouse=self.warehouse
)
self.item = item if isinstance(item, str) else item.item_code
def create_bundle(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
item2 = create_item(
item_code="_Test GP Item 2", is_stock_item=1, company=self.company, warehouse=self.warehouse
)
self.item2 = item2 if isinstance(item2, str) else item2.item_code
# This will be parent item
bundle = create_item(
item_code="_Test GP bundle", is_stock_item=0, company=self.company, warehouse=self.warehouse
)
self.bundle = bundle if isinstance(bundle, str) else bundle.item_code
# Create Product Bundle
self.product_bundle = make_product_bundle(parent=self.bundle, items=[self.item, self.item2])
def create_customer(self):
name = "_Test GP Customer"
if frappe.db.exists("Customer", name):
self.customer = name
else:
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
customer.save()
self.customer = customer.name
self.company = "_Test Company"
self.cost_center = "Main - _TC"
self.warehouse = "Stores - _TC"
self.finished_warehouse = "Finished Goods - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.debit_to = "Debtors - _TC"
self.item = "_Test Item"
self.item2 = "_Test Item Home Desktop 100"
self.bundle = "_Test Product Bundle Item"
self.customer = "_Test Customer"
def create_sales_invoice(
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
@@ -212,7 +158,7 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 1.0,
"avg._selling_rate": 100.0,
"valuation_rate": 150.0,
@@ -241,7 +187,7 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 1.0,
"avg._selling_rate": 100.0,
"valuation_rate": 100.0,
@@ -273,7 +219,7 @@ class TestGrossProfit(ERPNextTestSuite):
"item_code": self.item2,
"s_warehouse": "",
"t_warehouse": self.finished_warehouse,
"qty": 1,
"qty": 2,
"basic_rate": 100,
"conversion_factor": item.conversion_factor or 1.0,
"transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
@@ -373,7 +319,7 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 4.0,
"avg._selling_rate": 100.0,
"valuation_rate": 125.0,
@@ -414,10 +360,10 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 0.0,
"avg._selling_rate": 100,
"valuation_rate": 0.0,
"avg._selling_rate": 100.0,
"valuation_rate": 100.0,
"selling_amount": 0.0,
"buying_amount": 0.0,
"gross_profit": 0.0,
@@ -437,7 +383,7 @@ class TestGrossProfit(ERPNextTestSuite):
"""
# Make Cr Note
sinv = self.create_sales_invoice(
qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
qty=-1, rate=200, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
sinv.is_return = 1
sinv.items[0].allow_zero_valuation_rate = 1
@@ -460,14 +406,14 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": -1.0,
"avg._selling_rate": 100.0,
"valuation_rate": 0.0,
"selling_amount": -100.0,
"buying_amount": 0.0,
"avg._selling_rate": 200.0,
"valuation_rate": 100.0,
"selling_amount": -200.0,
"buying_amount": -100.0,
"gross_profit": -100.0,
"gross_profit_%": -100.0,
"gross_profit_%": -50.0,
}
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
report_output = {k: v for k, v in gp_entry[0].items() if k in expected_entry}
@@ -553,7 +499,7 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 4.0,
"avg._selling_rate": 800.0,
"valuation_rate": 700.0,
@@ -616,7 +562,7 @@ class TestGrossProfit(ERPNextTestSuite):
def test_gross_profit_groupby_invoices(self):
create_sales_invoice(
qty=1,
rate=100,
rate=200,
company=self.company,
customer=self.customer,
item_code=self.item,
@@ -638,10 +584,10 @@ class TestGrossProfit(ERPNextTestSuite):
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total.selling_amount, 100.0)
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.selling_amount, 200.0)
self.assertEqual(total.buying_amount, 100.0)
self.assertEqual(total.gross_profit, 100.0)
self.assertEqual(total.get("gross_profit_%"), 100.0)
self.assertEqual(total.get("gross_profit_%"), 50.0)
def test_profit_for_later_period_return(self):
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
@@ -650,7 +596,7 @@ class TestGrossProfit(ERPNextTestSuite):
return_inv_date = add_days(month_end_date, 1)
# create sales invoice on month start date
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_save=True, do_not_submit=True)
sinv.set_posting_time = 1
sinv.posting_date = sales_inv_date
sinv.save().submit()
@@ -669,10 +615,10 @@ class TestGrossProfit(ERPNextTestSuite):
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total.selling_amount, 100.0)
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.selling_amount, 200.0)
self.assertEqual(total.buying_amount, 100.0)
self.assertEqual(total.gross_profit, 100.0)
self.assertEqual(total.get("gross_profit_%"), 100.0)
self.assertEqual(total.get("gross_profit_%"), 50.0)
# extend filters upto returned period
filters.update({"to_date": return_inv_date})
@@ -690,10 +636,10 @@ class TestGrossProfit(ERPNextTestSuite):
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total.selling_amount, -100.0)
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.selling_amount, -200.0)
self.assertEqual(total.buying_amount, -100.0)
self.assertEqual(total.gross_profit, -100.0)
self.assertEqual(total.get("gross_profit_%"), -100.0)
self.assertEqual(total.get("gross_profit_%"), -50.0)
def test_sales_person_wise_gross_profit(self):
sales_person = make_sales_person("_Test Sales Person")
@@ -724,10 +670,10 @@ class TestGrossProfit(ERPNextTestSuite):
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total[5], 1000.0)
self.assertEqual(total[6], 0.0)
self.assertEqual(total[7], 1000.0)
self.assertEqual(total[8], 100.0)
self.assertEqual(total[5], 1000.0) # selling amount
self.assertEqual(total[6], 1000.0) # buying amount
self.assertEqual(total[7], 0.0) # gross profit
self.assertEqual(total[8], 0.0) # gross profit %
def test_drop_ship(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
@@ -754,6 +700,160 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertIsNone(data[1].buying_rate)
self.assertEqual(data[1]["gross_profit_%"], 20)
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
"""Create a rate adjustment debit note with no stock movement."""
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
if item_code:
dn.items[0].item_code = item_code
dn.items[0].item_name = item_code
dn.is_debit_note = 1
dn.return_against = against_invoice.name
dn.items[0].allow_zero_valuation_rate = 1
return dn.save().submit()
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
"""
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
since there is no stock movement. Gross profit equals the adjustment amount
and gross profit % equals 100%.
"""
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
sinv.update_stock = 1
sinv = sinv.save().submit()
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
)
columns, data = execute(filters=filters)
dn_item_rows = [
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
]
self.assertEqual(len(dn_item_rows), 1)
dn_row = dn_item_rows[0]
self.assertEqual(dn_row.buying_amount, 0.0)
self.assertEqual(dn_row.selling_amount, 20.0)
self.assertEqual(dn_row.gross_profit, 20.0)
self.assertEqual(dn_row["gross_profit_%"], 100.0)
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
"""
The original invoice's GP should be derived solely from its own selling
amount and COGS — the rate adjustment debit note must not alter it.
"""
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
sinv.update_stock = 1
sinv = sinv.save().submit()
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
)
columns, data = execute(filters=filters)
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
self.assertEqual(len(sinv_item_rows), 1)
sinv_row = sinv_item_rows[0]
self.assertEqual(sinv_row.selling_amount, 200.0)
self.assertEqual(sinv_row.buying_amount, 100.0)
self.assertEqual(sinv_row.gross_profit, 100.0)
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
def test_debit_note_qty_not_inflated_in_grouped_report(self):
"""
When grouped by Item Code, the debit note (qty=0) must not inflate
the group's qty or buying_amount. The selling amount and average
selling rate correctly reflect the rate adjustment.
"""
item = create_item("_Test Rate Adjustment Debit Note Item")
make_stock_entry(
company=self.company,
item_code=item.item_code,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = create_sales_invoice(
qty=1,
rate=200,
company=self.company,
customer=self.customer,
item_code=item.item_code,
item_name=item.item_code,
cost_center=self.cost_center,
warehouse=self.warehouse,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
update_stock=1,
currency="INR",
income_account=self.income_account,
expense_account=self.expense_account,
)
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Item Code",
)
columns, data = execute(filters=filters)
# group_by="Item Code" column order:
# [item_code, item_name, brand, description, qty, base_rate,
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
item_row = next((row for row in data if row[0] == item.item_code), None)
self.assertIsNotNone(item_row)
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
item_row[4],
item_row[5],
item_row[8],
item_row[7],
item_row[9],
item_row[10],
)
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
self.assertEqual(gross_profit, 120.0) # 220 - 100
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):

View File

@@ -9,42 +9,12 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestPaymentLedger(ERPNextTestSuite):
def setUp(self):
self.create_company()
self.cleanup()
def cleanup(self):
doctypes = []
doctypes.append(qb.DocType("GL Entry"))
doctypes.append(qb.DocType("Payment Ledger Entry"))
doctypes.append(qb.DocType("Sales Invoice"))
doctypes.append(qb.DocType("Payment Entry"))
for doctype in doctypes:
qb.from_(doctype).delete().where(doctype.company == self.company).run()
def create_company(self):
name = "Test Payment Ledger"
company = None
if frappe.db.exists("Company", name):
company = frappe.get_doc("Company", name)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": name,
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
)
company = company.save()
self.company = company.name
self.cost_center = company.cost_center
self.warehouse = "All Warehouses" + " - " + company.abbr
self.income_account = company.default_income_account
self.expense_account = company.default_expense_account
self.debit_to = company.default_receivable_account
self.company = "_Test Company"
self.cost_center = "Main - _TC"
self.warehouse = "Stores - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.debit_to = "Debtors - _TC"
def test_unpaid_invoice_outstanding(self):
sinv = create_sales_invoice(

View File

@@ -4,6 +4,7 @@
import frappe
from frappe import _, msgprint
from frappe.model.meta import get_field_precision
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, getdate
from pypika.terms import Bracket, LiteralValue, Order
@@ -125,17 +126,32 @@ def _execute(filters=None, additional_table_columns=None):
row.update({frappe.scrub(tax_acc): tax_amount})
# total tax, grand total, rounded total & outstanding amount
outstanding_precision = (
get_field_precision(
frappe.get_meta("Purchase Invoice").get_field("outstanding_amount"),
currency=company_currency,
)
or 2
)
row.update(
{
"total_tax": total_tax,
"grand_total": inv.base_grand_total,
"rounded_total": inv.base_rounded_total,
"outstanding_amount": inv.outstanding_amount,
}
)
if inv.doctype == "Purchase Invoice":
row.update({"debit": inv.base_grand_total, "credit": 0.0})
row.update(
{
"debit": inv.base_grand_total,
"credit": 0.0,
"outstanding_amount": flt(
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
),
}
)
else:
row.update({"debit": 0.0, "credit": inv.base_grand_total})
data.append(row)
@@ -395,6 +411,7 @@ def get_invoices(filters, additional_query_columns):
pi.base_rounded_total,
pi.outstanding_amount,
pi.mode_of_payment,
pi.conversion_rate,
)
.where(pi.docstatus == 1)
)

View File

@@ -2,7 +2,7 @@
# MIT License. See license.txt
import frappe
from frappe.utils import add_months, today
from frappe.utils import add_months, flt, today
from erpnext.accounts.report.purchase_register.purchase_register import execute
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -67,6 +67,35 @@ class TestPurchaseRegister(ERPNextTestSuite):
self.assertEqual(first_row.total_tax, 100)
self.assertEqual(first_row.grand_total, 1100)
def test_purchase_currency_conversion(self):
usd_creditors = frappe.get_doc(
{
"doctype": "Account",
"account_name": "USD Creditors",
"parent_account": "Accounts Payable - _TC",
"company": "_Test Company",
"account_type": "Payable",
"root_type": "Liability",
"report_type": "Balance Sheet",
"account_currency": "USD",
}
).insert()
foreign_invoice = make_purchase_invoice()
foreign_invoice.db_set("currency", "USD")
foreign_invoice.db_set("conversion_rate", 80)
foreign_invoice.db_set("credit_to", usd_creditors.name)
foreign_invoice.db_set("outstanding_amount", 100.236)
local_invoice = make_purchase_invoice()
local_invoice.db_set("currency", "INR")
local_invoice.db_set("conversion_rate", 1)
local_invoice.db_set("outstanding_amount", 200.456)
columns, data, *_ = execute(frappe._dict({"company": foreign_invoice.company}))
outstanding_precision = 2
data_by_name = {x.get("voucher_no"): x.get("outstanding_amount") for x in data}
self.assertEqual(data_by_name.get(foreign_invoice.name), flt((100.236 * 80), outstanding_precision))
self.assertEqual(data_by_name.get(local_invoice.name), flt(200.456, outstanding_precision))
def test_purchase_register_ledger_view(self):
filters = frappe._dict(
company="_Test Company 6",

View File

@@ -141,17 +141,31 @@ def _execute(filters, additional_table_columns=None):
# total tax, grand total, outstanding amount & rounded total
outstanding_precision = (
get_field_precision(
frappe.get_meta("Sales Invoice").get_field("outstanding_amount"),
currency=company_currency,
)
or 2
)
row.update(
{
"tax_total": total_tax,
"grand_total": inv.base_grand_total,
"rounded_total": inv.base_rounded_total,
"outstanding_amount": inv.outstanding_amount,
}
)
if inv.doctype == "Sales Invoice":
row.update({"debit": inv.base_grand_total, "credit": 0.0})
row.update(
{
"debit": inv.base_grand_total,
"credit": 0.0,
"outstanding_amount": flt(
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
),
}
)
else:
row.update({"debit": 0.0, "credit": inv.base_grand_total})
data.append(row)
@@ -437,6 +451,7 @@ def get_invoices(filters, additional_query_columns):
si.is_internal_customer,
si.represents_company,
si.company,
si.conversion_rate,
)
.where(si.docstatus == 1)
)

View File

@@ -1,9 +1,10 @@
import frappe
from frappe.utils import getdate, today
from frappe.utils import add_days, flt, getdate, today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_register.sales_register import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.selling.doctype.customer.test_customer import make_customer
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
@@ -216,3 +217,25 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
}
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
self.assertDictEqual(result_output, expected_result)
def test_outstanding_currency_conversion(self):
foreign_invoice = create_sales_invoice(
customer="_Test Customer",
posting_date=add_days(today(), -1),
qty=1,
rate=100,
)
foreign_invoice.db_set("currency", "USD")
foreign_invoice.db_set("conversion_rate", 80)
foreign_invoice.db_set("outstanding_amount", 100.236)
make_customer("_Test Customer2")
local_invoice = create_sales_invoice(
customer="_Test Customer2", currency="INR", conversion_rate=1, qty=1, rate=200
)
local_invoice.db_set("outstanding_amount", 200.456)
columns, data, *_ = execute(frappe._dict({"company": foreign_invoice.company}))
outstanding_precision = 2
data_by_name = {x.get("voucher_no"): x.get("outstanding_amount") for x in data}
self.assertEqual(data_by_name.get(foreign_invoice.name), flt((100.236 * 80), outstanding_precision))
self.assertEqual(data_by_name.get(local_invoice.name), flt(200.456, outstanding_precision))

View File

@@ -93,6 +93,11 @@ frappe.ui.form.on("Asset", {
frappe.ui.form.trigger("Asset", "asset_type");
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
if (frm.doc.docstatus < 1 && frm.doc.calculate_depreciation && frm.doc.is_fully_depreciated) {
// Is Fully Depreciated is read-only while depreciation is calculated, so keep it unchecked
frm.set_value("is_fully_depreciated", 0);
}
let has_create_buttons = false;
if (frm.doc.docstatus == 1) {
if (["Submitted", "Partially Depreciated"].includes(frm.doc.status)) {
@@ -726,6 +731,10 @@ frappe.ui.form.on("Asset", {
calculate_depreciation: function (frm) {
frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation);
if (frm.doc.calculate_depreciation && frm.doc.is_fully_depreciated) {
// Is Fully Depreciated is read-only while depreciation is calculated, so keep it unchecked
frm.set_value("is_fully_depreciated", 0);
}
if (frm.doc.item_code && frm.doc.calculate_depreciation && frm.doc.net_purchase_amount) {
frm.trigger("set_finance_book");
} else {

View File

@@ -450,10 +450,11 @@
},
{
"default": "0",
"depends_on": "eval:(doc.asset_type == \"Existing Asset\" && !doc.calculate_depreciation) || doc.calculate_depreciation",
"fieldname": "is_fully_depreciated",
"fieldtype": "Check",
"hidden": 1,
"label": "Is Fully Depreciated"
"label": "Is Fully Depreciated",
"read_only_depends_on": "eval:doc.calculate_depreciation"
},
{
"depends_on": "eval:doc.docstatus > 0",

View File

@@ -132,6 +132,10 @@ class Asset(AccountsController):
self.validate_gross_and_purchase_amount()
self.validate_finance_books()
if self.calculate_depreciation:
# Is Fully Depreciated is only applicable to manually entered existing assets
self.is_fully_depreciated = 0
def before_save(self):
self.total_asset_cost = self.net_purchase_amount + self.additional_asset_cost
self.status = self.get_status()

View File

@@ -100,6 +100,9 @@ class AssetCapitalization(StockController):
self.set_asset_values()
self.calculate_totals()
self.set_title()
# Asset Capitalization overrides validate() without calling super(), so the shared
# mandatory inventory dimension check must be invoked explicitly here.
self.validate_inventory_dimension_mandatory()
def on_update(self):
if self.stock_items:

View File

@@ -30,10 +30,7 @@ class BulkTransactionLog(Document):
def load_from_db(self):
log_detail = qb.DocType("Bulk Transaction Log Detail")
has_records = frappe.db.sql(
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
(self.name,),
)[0][0]
has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
if not has_records:
raise frappe.DoesNotExistError

View File

@@ -1,11 +1,76 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import nowtime, random_string
from erpnext.tests.utils import ERPNextTestSuite
class TestBulkTransactionLog(ERPNextTestSuite):
pass
def _make_log_doc(self, date):
# "Bulk Transaction Log" is a virtual doctype named by date; build the doc
# in-memory and drive load_from_db() directly to exercise the converted query.
doc = frappe.new_doc("Bulk Transaction Log")
doc.name = date
return doc
def _insert_detail(self, date, status="Success"):
detail = frappe.get_doc(
{
"doctype": "Bulk Transaction Log Detail",
"from_doctype": "Sales Order",
"to_doctype": "Sales Invoice",
"transaction_name": "_Test BTLD " + random_string(8),
"date": date,
"time": nowtime(),
"transaction_status": status,
}
)
# transaction_name is a Dynamic Link (options=from_doctype); the converted
# query never reads it, so skip link validation rather than create real txns.
detail.insert(ignore_permissions=True, ignore_links=True)
return detail
def test_load_raises_when_no_detail_rows(self):
# A date with zero Bulk Transaction Log Detail rows must not resolve to a log.
date = "2024-01-01"
self.assertFalse(
frappe.db.exists("Bulk Transaction Log Detail", {"date": date}),
"precondition: no detail rows for this date",
)
doc = self._make_log_doc(date)
self.assertRaises(frappe.DoesNotExistError, doc.load_from_db)
def test_load_succeeds_and_aggregates_after_detail_inserted(self):
date = "2024-02-02"
# Initially absent -> load_from_db must raise.
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(date).load_from_db)
# Insert detail rows for this date: 2 succeeded, 1 failed.
self._insert_detail(date, "Success")
self._insert_detail(date, "Success")
self._insert_detail(date, "Failed")
# Now the exists() check passes and load_from_db() populates aggregates.
doc = self._make_log_doc(date)
doc.load_from_db()
self.assertEqual(doc.date, date)
self.assertEqual(doc.succeeded, 2)
self.assertEqual(doc.failed, 1)
self.assertEqual(doc.log_entries, 3)
def test_load_isolated_per_date(self):
# Detail rows on a different date must not satisfy the lookup for our date.
other_date = "2024-03-03"
self._insert_detail(other_date, "Success")
target_date = "2024-04-04"
self.assertFalse(
frappe.db.exists("Bulk Transaction Log Detail", {"date": target_date}),
"target date has no rows; rows on another date must not leak in",
)
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(target_date).load_from_db)

View File

@@ -110,6 +110,7 @@
"fieldtype": "Data",
"in_global_search": 1,
"label": "Alias",
"no_copy": 1,
"unique": 1
},
{
@@ -561,7 +562,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-06-22 12:23:09.241125",
"modified": "2026-06-27 16:12:33.190257",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,7 @@ from frappe.contacts.doctype.address.address import get_address_display
from frappe.model.workflow import get_workflow_name
from frappe.query_builder import Criterion, DocType
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Sum
from frappe.query_builder.functions import Abs, IfNull, Sum
from frappe.utils import (
add_days,
add_months,
@@ -140,6 +140,26 @@ class AccountsController(TransactionBase):
if self.doctype in relevant_docs:
self.set_payment_schedule()
def before_insert(self):
self.clear_clearance_date_on_amend()
def clear_clearance_date_on_amend(self):
"""Drop the bank reconciliation clearance date copied over while amending.
The framework copies `no_copy` fields when amending, so a reconciled
voucher would carry a stale clearance date into its amendment even though
the linked bank transaction gets unreconciled on cancellation.
"""
if not self.get("amended_from"):
return
if self.meta.has_field("clearance_date"):
self.clearance_date = None
for payment in self.get("payments") or []:
if payment.meta.has_field("clearance_date"):
payment.clearance_date = None
def on_update(self):
from erpnext.controllers.taxes_and_totals import process_item_wise_tax_details
@@ -3491,8 +3511,18 @@ def get_common_query(
common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
if condition.get("accounting_dimensions"):
apply_strict_user_permissions = frappe.get_system_settings("apply_strict_user_permissions")
for field, val in condition.get("accounting_dimensions").items():
common_filter_conditions.append(payment_entry[field] == val)
if isinstance(val, list | tuple | set):
value_condition = payment_entry[field].isin(val)
if apply_strict_user_permissions:
common_filter_conditions.append(value_condition)
else:
common_filter_conditions.append(
(IfNull(payment_entry[field], "") == "") | value_condition
)
else:
common_filter_conditions.append(payment_entry[field] == val)
if condition.get("minimum_payment_amount"):
common_filter_conditions.append(
@@ -4153,6 +4183,7 @@ def update_child_qty_rate(
# if rate is greater than price_list_rate, set margin
# or set discount
child_item.discount_percentage = 0
child_item.discount_amount = 0
child_item.margin_type = "Amount"
child_item.margin_rate_or_amount = flt(
child_item.rate - child_item.price_list_rate,
@@ -4160,14 +4191,11 @@ def update_child_qty_rate(
)
child_item.rate_with_margin = child_item.rate
else:
child_item.discount_percentage = flt(
(1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0,
child_item.precision("discount_percentage"),
)
child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate)
child_item.margin_type = ""
child_item.margin_rate_or_amount = 0
child_item.rate_with_margin = 0
child_item.rate_with_margin = child_item.price_list_rate
child_item.discount_percentage = 0
child_item.discount_amount = flt(child_item.rate_with_margin) - flt(child_item.rate)
child_item.flags.ignore_validate_update_after_submit = True
if new_child_flag:

View File

@@ -177,6 +177,68 @@ def update_variant_attribute_values(item_attribute):
frappe.flags.attribute_values = None
def get_attribute_abbr_renames(item_attribute):
"""Return the set of (current) attribute values whose abbreviation was renamed."""
if item_attribute.numeric_values:
return set()
db_value = item_attribute.get_doc_before_save()
if not db_value:
return set()
old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values}
changed_values = set()
for row in item_attribute.item_attribute_values:
if row.name in old_abbrs and old_abbrs[row.name] != row.abbr:
changed_values.add(row.attribute_value)
return changed_values
def update_variant_item_codes_for_abbr_renames(item_attribute):
"""Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation."""
changed_values = get_attribute_abbr_renames(item_attribute)
if not changed_values:
return
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
variant_names = (
frappe.qb.from_(item_variant_table)
.select(item_variant_table.parent)
.where(item_variant_table.attribute == item_attribute.name)
.where(item_variant_table.attribute_value.isin(list(changed_values)))
.distinct()
.run(pluck=True)
)
for variant_name in variant_names:
rename_variant_item_code(variant_name)
def rename_variant_item_code(variant_name):
"""Recompute a variant's item_code/item_name from its template and current attribute abbreviations,
renaming the Item if it has changed."""
variant = frappe.get_doc("Item", variant_name)
if not variant.variant_of:
return
template = frappe.get_cached_doc("Item", variant.variant_of)
new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes})
make_variant_item_code(template.item_code, template.item_name, new_code)
if not new_code.item_code or new_code.item_code == variant.item_code:
return
frappe.rename_doc("Item", variant.item_code, new_code.item_code)
# Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so
# item_name is always rebuilt here too, even if it had since been customized away from that pattern.
if new_code.item_name and new_code.item_name != variant.item_name:
frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name)
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
allow_rename_attribute_value = frappe.db.get_single_value(
"Item Variant Settings", "allow_rename_attribute_value"

View File

@@ -144,7 +144,7 @@ def validate_returned_items(doc):
ref.rate
and flt(d.rate) > ref.rate
and doc.doctype in ("Delivery Note", "Sales Invoice")
and get_valuation_method(ref.item_code, doc.company) != "Moving Average"
and get_valuation_method(d.item_code, doc.company) != "Moving Average"
):
frappe.throw(
_("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format(

View File

@@ -143,7 +143,7 @@ status_map = {
],
[
"Partially Ordered",
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
],
],
"POS Opening Entry": [
@@ -166,7 +166,8 @@ status_map = {
"Pick List": [
["Draft", None],
["Open", "eval:self.docstatus == 1"],
["Completed", "stock_entry_exists"],
["Completed", "is_fully_transferred"],
["Partially Transferred", "is_partially_transferred"],
[
"Partly Delivered",
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",
@@ -383,15 +384,17 @@ class StatusUpdater(Document):
def fetch_items_with_pending_qty(self, args, item_field, items):
doctype = frappe.qb.DocType(args["target_dt"])
item_field = doctype[item_field]
item_field_col = doctype[item_field]
target_ref_field = doctype[args["target_ref_field"]]
target_field = doctype[args["target_field"]]
return (
is_qty_check = "qty" in args["target_ref_field"]
query = (
frappe.qb.from_(doctype)
.select(
doctype.name,
item_field.as_("item_code"),
item_field_col.as_("item_code"),
target_ref_field,
target_field,
doctype.parenttype,
@@ -400,9 +403,18 @@ class StatusUpdater(Document):
.where(target_ref_field < target_field)
.where(doctype.name.isin(items))
.where(doctype.docstatus == 1)
.run(as_dict=True)
)
if is_qty_check:
item_table = frappe.qb.DocType("Item")
query = (
query.join(item_table)
.on(item_table.name == item_field_col)
.where(item_table.is_stock_item == 1)
)
return query.run(as_dict=True)
def check_overflow_with_allowance(self, item, args):
"""
Checks if there is overflow considering a relaxation allowance.

View File

@@ -36,6 +36,8 @@ from erpnext.stock import get_warehouse_account_map
from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
get_evaluated_inventory_dimension,
get_mandatory_dimension_fields,
get_mandatory_inventory_dimensions,
)
from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
@@ -44,6 +46,42 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
)
from erpnext.stock.stock_ledger import get_items_to_be_repost
# Purposes whose inward (t_warehouse) row is inspected.
QI_INCOMING_PURPOSES = (
"Material Receipt",
"Repack",
"Receive from Customer",
"Subcontracting Return",
)
# Purposes whose outgoing (s_warehouse) row is inspected. This is an explicit
# allow-list rather than "everything that isn't incoming" so a new purpose can't
# silently start requiring a QI. Material Consumption for Manufacture is left out
# on purpose: an inspection_required BOM inspects the manufactured output (handled
# by the "Manufacture" finished-good rule), not each consumed raw material.
# Keep this in sync with erpnext.stock.qi_* helpers in transaction.js.
QI_OUTGOING_PURPOSES = (
"Material Issue",
"Material Transfer",
"Material Transfer for Manufacture",
"Send to Subcontractor",
"Subcontracting Delivery",
"Disassemble",
)
def stock_entry_row_requires_inspection(purpose, row):
"""Check if this Stock Entry row need a Quality Inspection."""
if row.get("type") or row.get("is_legacy_scrap_item"):
return False
if purpose == "Manufacture":
return bool(row.is_finished_item)
if purpose in QI_INCOMING_PURPOSES:
return bool(row.t_warehouse)
if purpose in QI_OUTGOING_PURPOSES:
return bool(row.s_warehouse and row.s_warehouse != row.t_warehouse)
return False
class StockController(AccountsController):
def validate(self):
@@ -64,6 +102,7 @@ class StockController(AccountsController):
self.validate_internal_transfer()
self.validate_putaway_capacity()
self.reset_conversion_factor()
self.validate_inventory_dimension_mandatory()
def on_update(self):
super().on_update()
@@ -348,6 +387,10 @@ class StockController(AccountsController):
parent_details = self.get_parent_details_for_packed_items()
for row in self.get(table_name):
item_code = row.get("rm_item_code") or row.get("item_code")
if not item_code or not self.is_serial_batch_item(item_code):
continue
if (
not via_landed_cost_voucher
and row.serial_and_batch_bundle
@@ -1140,6 +1183,50 @@ class StockController(AccountsController):
return item_account_wise_cost
def validate_inventory_dimension_mandatory(self):
# Mandatory inventory dimensions are enforced here (instead of via field-level `reqd`)
# so we can skip service rows and never block a document that is being cancelled.
if self.docstatus >= 2:
return
for table_field in ["items", "packed_items", "supplied_items"]:
rows = self.get(table_field)
if rows:
self.validate_mandatory_dimensions_in_table(rows)
def validate_mandatory_dimensions_in_table(self, rows):
child_doctype = rows[0].doctype
dimensions = get_mandatory_inventory_dimensions(child_doctype)
if not dimensions:
return
child_meta = frappe.get_meta(child_doctype)
for dimension in dimensions:
mandatory_fields = get_mandatory_dimension_fields(child_doctype, dimension)
for row in rows:
if mandatory_fields and not self.is_service_item_row(row):
self.validate_mandatory_dimension_row(row, dimension, mandatory_fields, child_meta)
def is_service_item_row(self, row) -> bool:
item_code = row.get("item_code")
return bool(item_code) and not frappe.get_cached_value("Item", item_code, "is_stock_item")
def validate_mandatory_dimension_row(self, row, dimension, mandatory_fields, child_meta):
for fieldname, condition in mandatory_fields:
if not child_meta.has_field(fieldname) or row.get(fieldname):
continue
if condition and not frappe.safe_eval(condition, {"doc": row, "parent": self}):
continue
frappe.throw(
_("Row #{0}: {1} is mandatory for the Inventory Dimension {2}.").format(
row.idx,
bold(_(child_meta.get_label(fieldname))),
bold(dimension.name),
)
)
def update_inventory_dimensions(self, row, sl_dict) -> None:
# To handle delivery note and sales invoice
if row.get("item_row"):
@@ -1430,8 +1517,8 @@ class StockController(AccountsController):
"Item", row.item_code, inspection_required_fieldname
):
qi_required = True
elif self.doctype == "Stock Entry" and row.t_warehouse:
qi_required = True # inward stock needs inspection
elif self.doctype == "Stock Entry":
qi_required = stock_entry_row_requires_inspection(self.purpose, row)
if row.get("type") or row.get("is_legacy_scrap_item"):
continue
@@ -1792,6 +1879,9 @@ class StockController(AccountsController):
"remarks": remarks,
}
if project:
gl_entry.update({"project": project})
if voucher_detail_no:
gl_entry.update({"voucher_detail_no": voucher_detail_no})
@@ -1970,6 +2060,7 @@ class StockController(AccountsController):
def show_accounting_ledger_preview(company, doctype, docname):
filters = frappe._dict(company=company, include_dimensions=1)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")
doc.run_method("before_gl_preview")
gl_columns, gl_data = get_accounting_ledger_preview(doc, filters)
@@ -1983,6 +2074,7 @@ def show_accounting_ledger_preview(company, doctype, docname):
def show_stock_ledger_preview(company, doctype, docname):
filters = frappe._dict(company=company)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")
doc.run_method("before_sl_preview")
sl_columns, sl_data = get_stock_ledger_preview(doc, filters)

View File

@@ -165,83 +165,85 @@ class calculate_taxes_and_totals:
self.doc.conversion_rate = flt(self.doc.conversion_rate)
def calculate_item_values(self):
if self.doc.get("is_consolidated"):
def calculate_item_rate(self, item):
if not item.price_list_rate:
remove_margin(item)
remove_discount(item)
item.rate_with_margin = 0
return
if not self.discount_amount_applied:
do_not_round_fields = ["valuation_rate", "incoming_rate"]
has_pricing_rules = item.pricing_rules and not self.doc.ignore_pricing_rule
if has_pricing_rules:
remove_margin(item)
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
for d in get_applied_pricing_rules(item.pricing_rules):
pricing_rule = frappe.get_cached_doc("Pricing Rule", d)
if item.discount_percentage == 100:
item.rate = 0.0
elif item.price_list_rate:
if not item.rate or (item.pricing_rules and item.discount_percentage > 0):
item.rate = flt(
item.price_list_rate * (1.0 - (item.discount_percentage / 100.0)),
item.precision("rate"),
)
item.discount_amount = item.price_list_rate * (item.discount_percentage / 100.0)
elif item.discount_amount and item.pricing_rules:
item.rate = item.price_list_rate - item.discount_amount
if item.doctype in [
"Quotation Item",
"Sales Order Item",
"Delivery Note Item",
"Sales Invoice Item",
"POS Invoice Item",
"Purchase Invoice Item",
"Purchase Order Item",
"Purchase Receipt Item",
]:
item.rate_with_margin, item.base_rate_with_margin = self.calculate_margin(item)
if flt(item.rate_with_margin) > 0:
item.rate = flt(
item.rate_with_margin * (1.0 - (item.discount_percentage / 100.0)),
item.precision("rate"),
)
if item.discount_amount and not item.discount_percentage:
item.rate = item.rate_with_margin - item.discount_amount
else:
item.discount_amount = flt(
item.rate_with_margin - item.rate, item.precision("discount_amount")
)
elif flt(item.price_list_rate) > 0:
item.discount_amount = flt(
item.price_list_rate - item.rate, item.precision("discount_amount")
)
elif flt(item.price_list_rate) > 0 and not item.discount_amount:
item.discount_amount = flt(
item.price_list_rate - item.rate, item.precision("discount_amount")
if not (
pricing_rule.margin_type
and pricing_rule.margin_rate_or_amount
and (
pricing_rule.margin_type == "Percentage" or pricing_rule.currency == self.doc.currency
)
item.net_rate = item.rate
if (
not item.qty
and self.doc.get("is_return")
and self.doc.get("doctype") != "Purchase Receipt"
):
item.amount = flt(-1 * item.rate, item.precision("amount"))
elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount"))
else:
item.amount = flt(item.rate * item.qty, item.precision("amount"))
continue
item.net_amount = item.amount
item.margin_type = pricing_rule.margin_type
item.margin_rate_or_amount = pricing_rule.margin_rate_or_amount
self._set_in_company_currency(
item, ["price_list_rate", "rate", "net_rate", "amount", "net_amount"]
)
item.rate_with_margin = get_rate_with_margin(item)
if item.discount_percentage > 0:
item.discount_amount = flt(
item.rate_with_margin * item.discount_percentage / 100.0, item.precision("discount_amount")
)
item.item_tax_amount = 0.0
calculated_rate = flt(item.rate_with_margin - item.discount_amount, item.precision("rate"))
# if rate is 0 or pricing rules are applicable, calculated rate is preferred
if has_pricing_rules or not item.rate:
item.rate = calculated_rate
return
# discount and margin are correct, exit early
if item.rate == calculated_rate:
return
# item rate does not match calculated rate. prefer item rate, reset margin / discount
if item.rate > item.price_list_rate:
item.margin_type = "Amount"
item.margin_rate_or_amount = flt(
item.rate - item.price_list_rate, item.precision("margin_rate_or_amount")
)
item.rate_with_margin = item.rate
remove_discount(item)
return
item.rate_with_margin = item.price_list_rate
item.discount_amount = flt(item.rate_with_margin - item.rate, item.precision("discount_amount"))
item.discount_percentage = 0
remove_margin(item)
def calculate_item_values(self):
if self.doc.get("is_consolidated") or self.discount_amount_applied:
return
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
self.calculate_item_rate(item)
item.net_rate = item.rate
if not item.qty and self.doc.get("is_return") and self.doc.get("doctype") != "Purchase Receipt":
item.amount = flt(-1 * item.rate, item.precision("amount"))
elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount"))
else:
item.amount = flt(item.rate * item.qty, item.precision("amount"))
item.net_amount = item.amount
self._set_in_company_currency(
item, ["price_list_rate", "rate_with_margin", "rate", "net_rate", "amount", "net_amount"]
)
item.item_tax_amount = 0.0
def _set_in_company_currency(self, doc, fields):
"""set values in base currency"""
@@ -1135,48 +1137,6 @@ class calculate_taxes_and_totals:
self.calculate_outstanding_amount()
def calculate_margin(self, item):
rate_with_margin = 0.0
base_rate_with_margin = 0.0
if item.price_list_rate:
if item.pricing_rules and not self.doc.ignore_pricing_rule:
has_margin = False
for d in get_applied_pricing_rules(item.pricing_rules):
pricing_rule = frappe.get_cached_doc("Pricing Rule", d)
if pricing_rule.margin_rate_or_amount and (
(
pricing_rule.currency == self.doc.currency
and pricing_rule.margin_type in ["Amount", "Percentage"]
)
or pricing_rule.margin_type == "Percentage"
):
item.margin_type = pricing_rule.margin_type
item.margin_rate_or_amount = pricing_rule.margin_rate_or_amount
has_margin = True
if not has_margin:
item.margin_type = None
item.margin_rate_or_amount = 0.0
if not item.pricing_rules and flt(item.rate) > flt(item.price_list_rate):
item.margin_type = "Amount"
item.margin_rate_or_amount = flt(
item.rate - item.price_list_rate, item.precision("margin_rate_or_amount")
)
item.rate_with_margin = item.rate
elif item.margin_type and item.margin_rate_or_amount:
margin_value = (
item.margin_rate_or_amount
if item.margin_type == "Amount"
else flt(item.price_list_rate) * flt(item.margin_rate_or_amount) / 100
)
rate_with_margin = flt(item.price_list_rate) + flt(margin_value)
base_rate_with_margin = flt(rate_with_margin) * flt(self.doc.conversion_rate)
return rate_with_margin, base_rate_with_margin
def set_item_wise_tax_breakup(self):
self.doc.other_charges_calculation = get_itemised_tax_breakup_html(self.doc)
@@ -1211,6 +1171,29 @@ class calculate_taxes_and_totals:
)
def remove_discount(item):
item.discount_percentage = 0.0
item.discount_amount = 0.0
def remove_margin(item):
item.margin_type = None
item.margin_rate_or_amount = 0.0
def get_rate_with_margin(item):
if not item.margin_type:
return item.price_list_rate
if item.margin_type == "Percentage":
return flt(
item.price_list_rate * (1 + (item.margin_rate_or_amount / 100.0)),
item.precision("rate_with_margin"),
)
return flt(item.price_list_rate + item.margin_rate_or_amount, item.precision("rate_with_margin"))
def get_itemised_tax_breakup_html(doc):
if not doc.taxes:
return

View File

@@ -18,39 +18,9 @@ from erpnext.buying.doctype.purchase_order.test_purchase_order import (
prepare_data_for_internal_transfer,
)
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestSuite
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Individual"
if currency:
customer.default_currency = currency
customer.save()
return customer.name
else:
return customer_name
def make_supplier(supplier_name, currency=None):
if not frappe.db.exists("Supplier", supplier_name):
supplier = frappe.new_doc("Supplier")
supplier.supplier_name = supplier_name
supplier.supplier_type = "Individual"
supplier.supplier_group = "All Supplier Groups"
if currency:
supplier.default_currency = currency
supplier.save()
return supplier.name
else:
return supplier_name
class TestAccountsController(ERPNextTestSuite):
"""
Test Exchange Gain/Loss booking on various scenarios.
@@ -67,79 +37,28 @@ class TestAccountsController(ERPNextTestSuite):
"""
def setUp(self):
self.create_company()
self.company = "_Test Company"
self.company_abbr = "_TC"
self.cost_center = "Main - _TC"
self.warehouse = "Stores - _TC"
self.finished_warehouse = "Finished Goods - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.debit_to = "Debtors - _TC"
self.debit_usd = "_Test Receivable USD - _TC"
self.debtors_usd = "_Test Receivable USD - _TC"
self.cash = "Cash - _TC"
self.creditors = "Creditors - _TC"
self.creditors_usd = "_Test Payable USD - _TC"
self.item = "_Test Item"
self.customer = "_Test Customer USD"
self.supplier = "_Test Supplier USD"
self.create_account()
self.create_item()
self.create_parties()
self.clear_old_entries()
frappe.flags.is_reverse_depr_entry = False
def create_company(self):
company_name = "_Test Company"
self.company_abbr = abbr = "_TC"
if frappe.db.exists("Company", company_name):
company = frappe.get_doc("Company", company_name)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": company_name,
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
)
company = company.save()
self.company = company.name
self.cost_center = company.cost_center
self.warehouse = "Stores - " + abbr
self.finished_warehouse = "Finished Goods - " + abbr
self.income_account = "Sales - " + abbr
self.expense_account = "Cost of Goods Sold - " + abbr
self.debit_to = "Debtors - " + abbr
self.debit_usd = "Debtors USD - " + abbr
self.cash = "Cash - " + abbr
self.creditors = "Creditors - " + abbr
def create_item(self):
item = create_item(
item_code="_Test Notebook", is_stock_item=0, company=self.company, warehouse=self.warehouse
)
self.item = item if isinstance(item, str) else item.item_code
def create_parties(self):
self.create_customer()
self.create_supplier()
def create_customer(self):
self.customer = make_customer("_Test MC Customer USD", "USD")
def create_supplier(self):
self.supplier = make_supplier("_Test MC Supplier USD", "USD")
def create_account(self):
# Advance accounts are not in persistent test data — create them on demand.
accounts = [
frappe._dict(
{
"attribute_name": "debtors_usd",
"name": "Debtors USD",
"account_type": "Receivable",
"account_currency": "USD",
"parent_account": "Accounts Receivable - " + self.company_abbr,
}
),
frappe._dict(
{
"attribute_name": "creditors_usd",
"name": "Creditors USD",
"account_type": "Payable",
"account_currency": "USD",
"parent_account": "Accounts Payable - " + self.company_abbr,
}
),
# Advance accounts under Asset and Liability header
frappe._dict(
{
"attribute_name": "advance_received_usd",
@@ -185,6 +104,7 @@ class TestAccountsController(ERPNextTestSuite):
company.save()
customer = frappe.get_doc("Customer", self.customer)
customer.accounts = []
customer.append(
"accounts",
{
@@ -196,6 +116,7 @@ class TestAccountsController(ERPNextTestSuite):
customer.save()
supplier = frappe.get_doc("Supplier", self.supplier)
supplier.accounts = []
supplier.append(
"accounts",
{
@@ -321,18 +242,6 @@ class TestAccountsController(ERPNextTestSuite):
pinv.submit()
return pinv
def clear_old_entries(self):
doctype_list = [
"GL Entry",
"Payment Ledger Entry",
"Sales Invoice",
"Purchase Invoice",
"Payment Entry",
"Journal Entry",
]
for doctype in doctype_list:
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
def create_payment_reconciliation(self):
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
@@ -955,7 +864,7 @@ class TestAccountsController(ERPNextTestSuite):
# Create a Sales Invoice
sinv = frappe.new_doc("Sales Invoice")
sinv.customer = self.customer
sinv.customer = "_Test Customer"
sinv.company = self.company
sinv.currency = "INR"
sinv.taxes_and_charges = "_Test Tax - _TC"
@@ -971,7 +880,7 @@ class TestAccountsController(ERPNextTestSuite):
def test_19_fetch_taxes_based_on_item_tax_template_template(self):
# Create a Sales Invoice
sinv = frappe.new_doc("Sales Invoice")
sinv.customer = self.customer
sinv.customer = "_Test Customer"
sinv.company = self.company
sinv.currency = "INR"
sinv.append(

View File

@@ -0,0 +1,77 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.controllers.stock_controller import (
show_accounting_ledger_preview,
show_stock_ledger_preview,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite
class TestLedgerPreviewPermission(ERPNextTestSuite):
def test_accounting_ledger_preview_requires_read_permission(self):
company = "_Test Company"
je = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
email = "ledger_preview_no_role@example.com"
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "No Role",
"user_type": "Website User",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
try:
frappe.set_user(email)
self.assertRaises(
frappe.PermissionError,
show_accounting_ledger_preview,
company,
"Journal Entry",
je.name,
)
finally:
frappe.set_user("Administrator")
# a permitted user is still able to read the preview
accounting_ledger_result = show_accounting_ledger_preview(company, "Journal Entry", je.name)
self.assertTrue(accounting_ledger_result.get("gl_data"))
def test_stock_ledger_preview_requires_read_permission(self):
company = "_Test Company"
pr = make_purchase_receipt()
email = "ledger_preview_no_role@example.com"
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "No Role",
"user_type": "Website User",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
try:
frappe.set_user(email)
self.assertRaises(
frappe.PermissionError,
show_stock_ledger_preview,
company,
"Purchase Receipt",
pr.name,
)
finally:
frappe.set_user("Administrator")
stock_ledger_result = show_stock_ledger_preview(company, "Purchase Receipt", pr.name)
self.assertTrue(stock_ledger_result.get("sl_data"))

View File

@@ -0,0 +1,36 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
from erpnext.tests.utils import ERPNextTestSuite
class TestWebsiteListForContact(ERPNextTestSuite):
def test_get_list_context_currency_symbols(self):
# get_list_context builds the enabled-currency symbol map via frappe.get_all (converted from
# raw SQL). Exercises that query and asserts a known enabled currency is present.
from erpnext.controllers.website_list_for_contact import get_list_context
context = get_list_context()
symbols = json.loads(context["currency_symbols"])
self.assertIsInstance(symbols, dict)
self.assertIn("USD", symbols)
def test_rfq_transaction_list_returns_supplier_rfq(self):
# rfq_transaction_list filters RFQs by the supplier (parties[0]) and uses SELECT DISTINCT with
# ORDER BY creation -- both must be valid on Postgres, and the supplier filter must compare to the
# party value (not a stray `party[0]` column reference).
from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import (
make_request_for_quotation,
)
from erpnext.controllers.website_list_for_contact import rfq_transaction_list
rfq = make_request_for_quotation()
supplier = rfq.suppliers[0].supplier
rows = rfq_transaction_list(
"Request for Quotation Supplier", "Request for Quotation", [supplier], 0, 20
)
self.assertIn(rfq.name, [row.name for row in rows])

View File

@@ -361,13 +361,24 @@ def based_wise_columns_query(based_on, trans):
# based_on_cols, based_on_select, based_on_group_by, addl_tables
if based_on == "Item":
based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"]
based_on_details["based_on_cols"] = [
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
]
based_on_details["based_on_select"] = "t2.item_code, t2.item_name,"
based_on_details["based_on_group_by"] = "t2.item_code"
based_on_details["addl_tables"] = ""
elif based_on == "Item Group":
based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Item Group"),
"fieldtype": "Link",
"options": "Item Group",
"width": 120,
"fieldname": "item_group",
}
]
based_on_details["based_on_select"] = "t2.item_group,"
based_on_details["based_on_group_by"] = "t2.item_group"
based_on_details["addl_tables"] = ""
@@ -375,32 +386,80 @@ def based_wise_columns_query(based_on, trans):
elif based_on == "Customer":
if trans == "Quotation":
based_on_details["based_on_cols"] = [
"Party:Link/Customer:120",
"Party Name:Data:120",
"Territory:Link/Territory:120",
{
"label": _("Party"),
"fieldtype": "Link",
"options": "Customer",
"width": 120,
"fieldname": "party",
},
{"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"},
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
},
]
based_on_details["based_on_select"] = "t1.party_name, t1.customer_name, t1.territory,"
else:
based_on_details["based_on_cols"] = [
"Customer:Link/Customer:120",
"Customer Name:Data:120",
"Territory:Link/Territory:120",
{
"label": _("Customer"),
"fieldtype": "Link",
"options": "Customer",
"width": 120,
"fieldname": "customer",
},
{
"label": _("Customer Name"),
"fieldtype": "Data",
"width": 120,
"fieldname": "customer_name",
},
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
},
]
based_on_details["based_on_select"] = "t1.customer, t1.customer_name, t1.territory,"
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
based_on_details["addl_tables"] = ""
elif based_on == "Customer Group":
based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"]
based_on_details["based_on_cols"] = [
{
"label": _("Customer Group"),
"fieldtype": "Link",
"options": "Customer Group",
"fieldname": "customer_group",
}
]
based_on_details["based_on_select"] = "t1.customer_group,"
based_on_details["based_on_group_by"] = "t1.customer_group"
based_on_details["addl_tables"] = ""
elif based_on == "Supplier":
based_on_details["based_on_cols"] = [
"Supplier:Link/Supplier:120",
"Supplier Name:Data:120",
"Supplier Group:Link/Supplier Group:140",
{
"label": _("Supplier"),
"fieldtype": "Link",
"options": "Supplier",
"width": 120,
"fieldname": "supplier",
},
{"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"},
{
"label": _("Supplier Group"),
"fieldtype": "Link",
"options": "Supplier Group",
"width": 140,
"fieldname": "supplier_group",
},
]
based_on_details["based_on_select"] = "t1.supplier, t1.supplier_name, t3.supplier_group,"
based_on_details["based_on_group_by"] = "t1.supplier"
@@ -408,26 +467,58 @@ def based_wise_columns_query(based_on, trans):
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
elif based_on == "Supplier Group":
based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"]
based_on_details["based_on_cols"] = [
{
"label": _("Supplier Group"),
"fieldtype": "Link",
"options": "Supplier Group",
"width": 140,
"fieldname": "supplier_group",
}
]
based_on_details["based_on_select"] = "t3.supplier_group,"
based_on_details["based_on_group_by"] = "t3.supplier_group"
based_on_details["addl_tables"] = ",`tabSupplier` t3"
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
elif based_on == "Territory":
based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
}
]
based_on_details["based_on_select"] = "t1.territory,"
based_on_details["based_on_group_by"] = "t1.territory"
based_on_details["addl_tables"] = ""
elif based_on == "Project":
if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]:
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Project"),
"fieldtype": "Link",
"options": "Project",
"width": 120,
"fieldname": "project",
}
]
based_on_details["based_on_select"] = "t1.project,"
based_on_details["based_on_group_by"] = "t1.project"
based_on_details["addl_tables"] = ""
elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]:
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Project"),
"fieldtype": "Link",
"options": "Project",
"width": 120,
"fieldname": "project",
}
]
based_on_details["based_on_select"] = "t2.project,"
based_on_details["based_on_group_by"] = "t2.project"
based_on_details["addl_tables"] = ""
@@ -435,7 +526,15 @@ def based_wise_columns_query(based_on, trans):
frappe.throw(_("Project-wise data is not available for Quotation"))
based_on_details["based_on_select"] += "t4.default_currency as currency,"
based_on_details["based_on_cols"].append("Currency:Link/Currency:120")
based_on_details["based_on_cols"].append(
{
"label": _("Currency"),
"fieldtype": "Link",
"options": "Currency",
"width": 120,
"fieldname": "currency",
}
)
based_on_details["addl_tables"] += ", `tabCompany` t4"
based_on_details["addl_tables_relational_cond"] = (
based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name"
@@ -446,6 +545,14 @@ def based_wise_columns_query(based_on, trans):
def group_wise_column(group_by):
if group_by:
return [group_by + ":Link/" + group_by + ":120"]
return [
{
"label": _(group_by),
"fieldtype": "Link",
"options": group_by,
"width": 120,
"fieldname": frappe.scrub(group_by),
}
]
else:
return []

View File

@@ -181,9 +181,10 @@ def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_p
party = frappe.qb.DocType(parties_doctype)
data = (
frappe.qb.from_(party)
.select(party.parent.as_("name"), party.supplier)
# creation must be selected: Postgres requires SELECT DISTINCT order-by exprs in the select list
.select(party.parent.as_("name"), party.supplier, party.creation)
.distinct()
.where((party.supplier == party[0]) & (party.docstatus == 1))
.where((party.supplier == parties[0]) & (party.docstatus == 1))
.orderby(party.creation, order=frappe.qb.desc)
.limit(limit_page_length)
.offset(limit_start)

View File

@@ -2,6 +2,35 @@
// For license information, please see license.txt
frappe.ui.form.on("CRM Settings", {
// refresh: function(frm) {
// }
refresh: function (frm) {
const flag = frm.events.calculate_visiblity_flag(frm);
frm.set_df_property("allowed_users", "hidden", !flag);
frm.set_df_property("allowed_users", "reqd", flag);
},
enable_frappe_crm_data_synchronization: function (frm) {
const flag = frm.events.calculate_visiblity_flag(frm);
if (flag) {
frappe.show_alert(
__("Allowed Users is required for data synchronization from remote Frappe CRM site.")
);
}
/*
make allowed_users field visible and mandatory if enable_frappe_crm_data_synchronization
is set and crm app is not installed.
*/
frm.set_df_property("allowed_users", "hidden", !flag);
frm.set_df_property("allowed_users", "reqd", flag);
},
calculate_visiblity_flag: function (frm) {
const crm_sync_enabled = frm.doc.enable_frappe_crm_data_synchronization;
const is_crm_installed = cint(frappe.utils.get_installed_apps().includes("crm"));
return crm_sync_enabled && !is_crm_installed;
},
});

View File

@@ -120,9 +120,9 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
"fieldname": "allowed_users",
"fieldtype": "Table MultiSelect",
"hidden": 1,
"label": "Allowed Users",
"options": "Frappe CRM Allowed User",
"permlevel": 1
@@ -140,7 +140,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-22 01:26:13.474915",
"modified": "2026-07-01 01:09:16.461470",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

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