Compare commits

..

402 Commits

Author SHA1 Message Date
Nabin Hait
e3e3d97a72 Merge pull request #56809 from frappe/chore/test-custom-financial-statement
test: Custom Financial Statement report coverage
2026-07-03 00:33:08 +05:30
Nabin Hait
42c6768b4c test: guard period_keys index access for clearer failure 2026-07-03 00:16:06 +05:30
Nabin Hait
2e13691ffa Merge pull request #56808 from frappe/chore/test-dimension-wise-accounts-balance
test: Dimension-wise Accounts Balance report coverage
2026-07-03 00:14:30 +05:30
Nabin Hait
ae80d29dcf Merge pull request #56792 from frappe/chore/test-timesheet-billing-summary
test: Timesheet Billing Summary report coverage
2026-07-03 00:13:39 +05:30
Nabin Hait
a33f7ead24 Merge pull request #56791 from frappe/chore/test-project-summary
test: Project Summary report coverage
2026-07-03 00:13:28 +05:30
Nabin Hait
817ecaa92f Merge pull request #56790 from frappe/chore/test-lead-owner-efficiency
test: Lead Owner Efficiency report coverage
2026-07-03 00:12:44 +05:30
Nabin Hait
8fd98ccbe2 Merge pull request #56789 from frappe/chore/test-supplier-quotation-comparison
test: Supplier Quotation Comparison report coverage
2026-07-03 00:12:34 +05:30
Nabin Hait
bc82197bd1 Merge pull request #56807 from frappe/chore/test-accounts-payable-summary
test: Accounts Payable Summary report coverage
2026-07-02 23:35:22 +05:30
Nabin Hait
9c53a91b82 test: add simulate=True to draft timesheet for overlap safety 2026-07-02 23:32:02 +05:30
Nabin Hait
21a9f2754e test: unpack report_summary tuple and key labels via _() 2026-07-02 23:31:13 +05:30
Nabin Hait
f0434cadd4 test: guard against missing owner row before subscripting 2026-07-02 23:30:34 +05:30
Nabin Hait
0ba43a17c1 test: strengthen price_per_unit assertion, drop no-op quotation guard 2026-07-02 23:29:48 +05:30
Nabin Hait
4feaacc649 test: Custom Financial Statement report coverage 2026-07-02 23:26:38 +05:30
Nabin Hait
7034dc71e7 test: Dimension-wise Accounts Balance report coverage 2026-07-02 23:24:12 +05:30
Nabin Hait
ed72732bb2 test: Accounts Payable Summary report coverage 2026-07-02 23:21:36 +05:30
Nabin Hait
b12725c4b2 Merge pull request #56778 from frappe/chore/test-quotation-trends
test: Quotation Trends report coverage
2026-07-02 23:07:02 +05:30
Nabin Hait
20928bd600 Merge pull request #56796 from frappe/chore/fix-flaky-bom-cost-valuation-reset
test: fix flaky test_update_bom_cost_in_all_boms via valuation reset
2026-07-02 23:05:22 +05:30
Nabin Hait
15862566a8 Merge pull request #56784 from frappe/chore/test-territory-wise-sales
test: Territory-wise Sales report coverage
2026-07-02 23:04:22 +05:30
Nabin Hait
e446f54f2e Merge pull request #56787 from frappe/chore/test-purchase-analytics
test: Purchase Analytics report coverage
2026-07-02 23:04:10 +05:30
Nabin Hait
cf9f16a921 Merge pull request #56788 from frappe/chore/test-subcontract-order-summary
test: Subcontract Order Summary report coverage
2026-07-02 23:03:57 +05:30
Nabin Hait
6cffa0faeb Merge pull request #56783 from frappe/chore/test-sales-person-wise-transaction-summary
fix: correct filter handling in Sales Person-wise Transaction Summary + tests
2026-07-02 22:58:48 +05:30
Nabin Hait
a075437db7 Merge pull request #56780 from frappe/chore/test-customer-wise-item-price
test: Customer-wise Item Price report coverage
2026-07-02 22:57:19 +05:30
Nabin Hait
9f4914e08f Merge pull request #56781 from frappe/chore/test-sales-person-commission-summary
test: Sales Person Commission Summary report coverage
2026-07-02 22:56:20 +05:30
Mihir Kandoi
c9d712fa49 Merge pull request #56800 from aerele/fix/wo-status-partial-pick
fix(manufacturing): update work order status on partial pick-list transfer
2026-07-02 21:44:46 +05:30
Mihir Kandoi
3345336a5c Merge pull request #56798 from aerele/fix/sre-auto-reserve
fix: skip stock reservation for opted-out production plans
2026-07-02 21:42:54 +05:30
MochaMind
ceadc4f269 fix: sync translations from crowdin (#56673) 2026-07-02 17:08:31 +02:00
Shllokkk
caa4358057 fix: guard against missing DocType in onboarding steps patch (#56804) 2026-07-02 19:33:16 +05:30
Nabin Hait
36f56fa1c3 Merge pull request #56786 from frappe/chore/test-territory-target-variance
test: Territory Target Variance based on Item Group report coverage
2026-07-02 18:09:21 +05:30
Raffael Meyer
5b738b7b0d 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
2026-07-02 12:33:26 +00:00
Sudharsanan11
f85f6be3cf 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-02 17:39:33 +05:30
Sudharsanan11
6591ae195d fix(manufacturing): update work order status on partial pick-list transfer
A stock entry created from a pick list has fg_completed_qty=0, so
material_transferred_for_manufacturing is derived from the min-fraction of
item-level transfers. When a pick list moves only some required items, the
un-picked item stays at 0, which zeroes the aggregate and leaves the work
order status at "not started" even though material is already in wip.

Promote the status to "in process" when any raw material has been transferred
via a pick list. material_transferred_for_manufacturing stays min-fraction
based (0 correctly means no full finished good can be started yet).
2026-07-02 17:38:14 +05:30
Shllokkk
7229957107 Merge pull request #56747 from Shllokkk/create-payment-entries-from-payable-report
fix: surface create payment entries as primary action on row selection
2026-07-02 17:33:56 +05:30
Nabin Hait
50b6f50b88 test: assert root rollup and no item leak in Purchase Analytics 2026-07-02 17:16:20 +05:30
Nabin Hait
0888405640 test: reuse shared distribution helper and assert a single territory row 2026-07-02 17:15:05 +05:30
Nabin Hait
087fb29d51 test: narrow Territory-wise Sales docstring to covered stages 2026-07-02 17:13:42 +05:30
Nabin Hait
2bab709ac4 test: scope date range, reload invoice, strengthen total-row check 2026-07-02 17:12:58 +05:30
Nabin Hait
5298438905 test: also assert Jun amount bucket in Quotation Trends monthly test 2026-07-02 17:11:37 +05:30
Nabin Hait
e0b0926dff fix: only resolve items when item_group/brand filter is set 2026-07-02 17:10:19 +05:30
Nabin Hait
a3d22f4a51 chore: re-trigger CI (unrelated flaky shard) 2026-07-02 17:03:54 +05:30
Nabin Hait
3f9b8fe37e test: reconcile negative-stock warehouses in reset_item_valuation_rate 2026-07-02 17:03:32 +05:30
pandiyan
820f5498e7 test: cover reserve stock gating on purchase receipt submit 2026-07-02 16:56:44 +05:30
pandiyan
71f02d412a fix: skip stock reservation for opted-out production plans 2026-07-02 16:56:34 +05:30
Nabin Hait
f9ac05f4a1 test: Timesheet Billing Summary report coverage 2026-07-02 15:54:55 +05:30
Nabin Hait
c7fed29569 test: Project Summary report coverage 2026-07-02 15:53:19 +05:30
Nabin Hait
5514c64b7c test: Lead Owner Efficiency report coverage 2026-07-02 15:51:22 +05:30
Nabin Hait
2a8d26c0a7 test: Supplier Quotation Comparison report coverage 2026-07-02 15:49:52 +05:30
Nabin Hait
56e7690e64 test: Subcontract Order Summary report coverage 2026-07-02 15:47:57 +05:30
Nabin Hait
6e57bd325f test: Purchase Analytics report coverage 2026-07-02 15:45:50 +05:30
Nabin Hait
8d70385019 test: Territory Target Variance based on Item Group report coverage 2026-07-02 15:43:59 +05:30
Nabin Hait
f95baa54de test: Territory-wise Sales report coverage 2026-07-02 15:42:07 +05:30
Nabin Hait
3092c920ff fix: pass only valid document filters in Sales Person-wise Transaction Summary 2026-07-02 15:40:32 +05:30
Nabin Hait
55646667be test: Sales Person Commission Summary report coverage 2026-07-02 15:36:47 +05:30
Nabin Hait
9865f63613 test: Customer-wise Item Price report coverage 2026-07-02 15:33:53 +05:30
Nabin Hait
08876ae07a test: Quotation Trends report coverage 2026-07-02 15:32:13 +05:30
Nabin Hait
489a799bc4 Merge pull request #56729 from frappe/chore/test-production-analytics
test: Production Analytics report coverage
2026-07-02 15:23:44 +05:30
Nabin Hait
0790d2e6df fix(manufacturing): include last-day records in Production Analytics
`get_work_orders` bounded a BETWEEN on the datetime columns `creation`
and `actual_end_date` with a bare date `to_date`, which MariaDB coerces
to midnight. Work orders created after 00:00:00 on the period's last day
were therefore dropped from the report (and made the new coverage test
fail on month-end CI runs). Extend `to_date` to end of day.
2026-07-02 15:09:00 +05:30
Nabin Hait
04b94ed61f Merge pull request #56766 from frappe/chore/budget-variance-zero-actuals-guard
test: zero pre-committed actuals in Budget Variance report tests
2026-07-02 15:06:48 +05:30
Nabin Hait
334b8ab09a Merge pull request #56733 from frappe/chore/test-work-order-consumed-materials
test: Work Order Consumed Materials report coverage
2026-07-02 15:06:10 +05:30
Nabin Hait
7592f568ae Merge pull request #56731 from frappe/chore/test-quality-inspection-summary
test: Quality Inspection Summary report coverage
2026-07-02 15:06:00 +05:30
Nabin Hait
bd21f506a1 Merge pull request #56730 from frappe/chore/test-bom-explorer
test: BOM Explorer report coverage
2026-07-02 15:05:33 +05:30
Nabin Hait
2eec826219 Merge pull request #56728 from frappe/chore/test-job-card-summary
test: Job Card Summary report coverage
2026-07-02 15:01:50 +05:30
Nabin Hait
4716084a41 Merge pull request #56725 from frappe/chore/test-consolidated-financial-statement
fix: Consolidated Financial Statement total double-count + test coverage
2026-07-02 15:01:28 +05:30
Nabin Hait
81e838c4f8 Merge pull request #56724 from frappe/chore/test-share-ledger
test: Share Ledger report coverage
2026-07-02 15:00:36 +05:30
Nabin Hait
c8eebd3a96 Merge pull request #56722 from frappe/chore/test-bank-clearance-summary
test: Bank Clearance Summary report coverage
2026-07-02 14:59:39 +05:30
Nabin Hait
b6bdf81ce8 Merge pull request #56738 from frappe/chore/test-production-plan-summary
test: Production Plan Summary report coverage
2026-07-02 14:58:07 +05:30
Nabin Hait
64db8072d8 Merge pull request #56739 from frappe/chore/test-exponential-smoothing-forecasting
test: Exponential Smoothing Forecasting report coverage
2026-07-02 14:57:56 +05:30
Nabin Hait
cabdb7417d Merge pull request #56767 from frappe/chore/incorrect-balance-qty-negative-case
test: cover inconsistent balance detection in Incorrect Balance Qty report
2026-07-02 14:57:08 +05:30
Nabin Hait
b4d3a879d2 Merge pull request #56759 from frappe/chore/fix-payment-period-range-buckets
fix: bucket late payments into 90 Above in Payment Period report
2026-07-02 14:56:36 +05:30
Nabin Hait
040b33070b Merge pull request #56765 from frappe/chore/profitability-analysis-unique-cost-centers
test: isolate Profitability Analysis tests from shared cost centers
2026-07-02 14:56:26 +05:30
Nabin Hait
dae3a21b61 Merge pull request #56762 from frappe/chore/cogs-by-item-group-scoping
test: isolate COGS By Item Group test with a dedicated item group
2026-07-02 14:55:54 +05:30
Nabin Hait
0769484fd6 Merge pull request #56761 from frappe/chore/item-wise-consumption-total-amount
test: isolate Item-wise Consumption test with a unique item
2026-07-02 14:53:47 +05:30
Nabin Hait
683ef19b8a Merge pull request #56760 from frappe/chore/fix-share-balance-company-filter
fix: scope Share Balance report to the selected company
2026-07-02 14:53:35 +05:30
rohitwaghchaure
0e8ae7548d fix: block serialized to non-serialized item change when SABB exists (#56773) 2026-07-02 08:55:49 +00:00
Nabin Hait
7f05b8ce58 Merge pull request #56734 from frappe/chore/test-bom-variance-report
test: BOM Variance Report report coverage
2026-07-02 14:25:37 +05:30
Nabin Hait
e92a9c706b Merge pull request #56736 from frappe/chore/test-cost-of-poor-quality-report
test: Cost of Poor Quality Report report coverage
2026-07-02 14:25:09 +05:30
Nabin Hait
18d1947154 test: assert to_date upper bound and use assertIsNone in Bank Clearance Summary 2026-07-02 14:13:29 +05:30
Nabin Hait
a69590b609 test: named column indices and Transfer-label coverage in Share Ledger 2026-07-02 14:12:24 +05:30
Nabin Hait
5adbc7baba test: target leaf accounts and robust amount assertions in Consolidated Financial Statement 2026-07-02 14:10:24 +05:30
Nabin Hait
7e7fd610cb test: guard job card list and derive status filter from stored status 2026-07-02 14:08:19 +05:30
Nabin Hait
ece8c9538d test: locale-safe status match and stable period window in Production Analytics 2026-07-02 14:06:37 +05:30
Nabin Hait
b77f6168d9 test: load BOM fixtures and scope to top-level rows in BOM Explorer test 2026-07-02 14:05:15 +05:30
Nabin Hait
14f862f80c test: add positive item_code filter case in Quality Inspection Summary 2026-07-02 14:03:45 +05:30
Nabin Hait
f1e91b6be6 test: add positive anchor and robust row pairing in Work Order Consumed Materials 2026-07-02 14:02:45 +05:30
Nabin Hait
835a050cfb test: cover produced-on-plan exclusion in BOM Variance report 2026-07-02 14:01:22 +05:30
Nabin Hait
2d3a1f5fab fix: expose hour rate column in Cost of Poor Quality report + robust float assert 2026-07-02 14:00:02 +05:30
Nabin Hait
14091a8996 fix: report full planned qty as pending when a plan has no work order 2026-07-02 13:58:31 +05:30
Nabin Hait
cc9d94efe8 test: use unique item and assert exact forecast in Exponential Smoothing test 2026-07-02 13:57:07 +05:30
Nabin Hait
c17517d22a test: use a unique item group per run in COGS test 2026-07-02 13:55:16 +05:30
Nabin Hait
4c9520bb1f Merge pull request #56769 from frappe/chore/negative-batch-report-negative-case
test: cover negative-batch detection in Negative Batch Report
2026-07-02 13:42:27 +05:30
Kavin
7248053c6a feat(stock): add configurable Stock Delivered But Not Billed (SDBNB) support (#56070)
* feat: add company setting to enable Stock Delivered But Not Billed accounting

* test: add tests for Stock Delivered But Not Billed account config

* fix(company): skip outstanding SDBNB validation when no previous config exists

* test: add dedicated company fixture for SDBNB tests

* test: use SDBNB company for Sales Invoice SDBNB test

---------

Co-authored-by: Pugazhendhi Velu <pugazhendhi720@gmail.com>
Co-authored-by: Pugazhendhi Velu <126157273+PugazhendhiVelu@users.noreply.github.com>
2026-07-02 13:34:25 +05:30
Mihir Kandoi
c98ca6d2cc Merge pull request #56771 from mihir-kandoi/pg/advisory-lock-postgres-only
fix: restrict repost advisory-lock gate to Postgres
2026-07-02 13:27:49 +05:30
Jatin3128
0a05dd4426 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
2026-07-02 13:17:14 +05:30
Mihir Kandoi
99fbd61bd9 fix: restrict repost advisory-lock gate to Postgres
MariaDB falls back to the existing deadlock-retry path; the advisory-lock
serialization from #56697 now applies on Postgres only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 13:15:12 +05:30
Nabin Hait
b9e321c106 test: cover negative-batch detection in Negative Batch Report 2026-07-02 12:57:16 +05:30
Nabin Hait
27f5235e67 test: cover inconsistent balance detection in Incorrect Balance Qty report 2026-07-02 12:48:29 +05:30
Nabin Hait
694328aab6 test: zero pre-committed actuals in Budget Variance report tests 2026-07-02 12:46:53 +05:30
Nabin Hait
cb4f3588fa test: isolate Profitability Analysis tests from shared cost centers 2026-07-02 12:45:01 +05:30
Mihir Kandoi
7835f11f96 Merge pull request #56757 from frappe/fix/stock-ageing-negative-batch-head
fix: don't treat batch slot at FIFO queue head as qty slot
2026-07-02 12:43:53 +05:30
Nabin Hait
2e72c13aee test: isolate COGS By Item Group test with a dedicated item group 2026-07-02 12:43:07 +05:30
Nabin Hait
898a70d340 test: isolate Item-wise Consumption test with a unique item 2026-07-02 12:41:18 +05:30
Nabin Hait
435998cc4e Merge pull request #56720 from frappe/chore/test-billed-items-to-be-received
fix: Billed Items To Be Received invoice filter + test coverage
2026-07-02 12:38:42 +05:30
Nabin Hait
ca7c6ca6da fix: scope Share Balance report to the selected company 2026-07-02 12:36:38 +05:30
Nabin Hait
d10504af03 fix: bucket late payments into 90 Above in Payment Period report 2026-07-02 12:34:38 +05:30
Nabin Hait
63cf379dbf Merge pull request #56743 from frappe/chore/fix-process-loss-report-filters
fix: apply item and work_order filters in Process Loss Report
2026-07-02 12:32:16 +05:30
Nabin Hait
65e3394481 Merge pull request #56749 from frappe/chore/strengthen-bom-operations-time-filters
test: strengthen BOM Operations Time filter isolation coverage
2026-07-02 12:32:00 +05:30
Mihir Kandoi
8928b42d5d test: assert full negative batch slot in ageing regression test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:31:24 +05:30
Mihir Kandoi
c47a95a4d2 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>
2026-07-02 12:25:52 +05:30
ruthra kumar
5f83887334 Merge pull request #56754 from ruthra-kumar/update_process_statement_print_title
refactor: update title for process statement of accounts
2026-07-02 12:12:14 +05:30
ruthra kumar
04468c3c33 refactor: update title for process statement of accounts 2026-07-02 11:56:17 +05:30
Nabin Hait
f9029f8644 chore: re-trigger CI (infra untar failure) 2026-07-02 11:35:04 +05:30
Nabin Hait
8b3c3d9fef chore: re-trigger CI (infra untar failure) 2026-07-02 11:35:01 +05:30
Nabin Hait
2333afcd1e chore: re-trigger CI (infra untar failure) 2026-07-02 11:34:58 +05:30
Nabin Hait
4062e92c17 Merge pull request #56717 from frappe/chore/test-calculated-discount-mismatch
test: Calculated Discount Mismatch report coverage
2026-07-02 11:34:49 +05:30
Nabin Hait
7023817a71 chore: re-trigger CI (infra untar failure) 2026-07-02 11:08:49 +05:30
Nabin Hait
c1fb7d0545 Merge pull request #56727 from frappe/chore/test-work-order-summary
test: Work Order Summary report coverage
2026-07-02 11:05:30 +05:30
Nihantra C. Patel
cab1b129c0 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.
2026-07-02 10:16:57 +05:30
Nabin Hait
0f812e0686 test: strengthen BOM Operations Time filter isolation coverage 2026-07-02 07:54:52 +05:30
Mihir Kandoi
171f12c2eb Merge pull request #56697 from mihir-kandoi/pg/advisory-lock
perf: serialize concurrent reposts with an advisory lock
2026-07-02 07:54:03 +05:30
Diptanil Saha
9cea43b006 fix(company): ignore user permissions for link fields having link to Account and Cost Center (#56748) 2026-07-02 07:42:34 +05:30
Mihir Kandoi
15adc92e76 Merge pull request #56744 from mihir-kandoi/pg/repost-recovery-by-exception-type
fix: classify repost recovery by exception type, not traceback string
2026-07-02 07:34:34 +05:30
Raffael Meyer
ba1e8f0005 fix(Quotation): create Customer from Lead (#55923) 2026-07-02 03:27:14 +02:00
Soham Kulkarni
3eaea74a51 Merge pull request #56656 from sokumon/revamp-workspaces
chore: exporting workspaces with sidebars
2026-07-02 05:39:24 +05:30
sokumon
d84eb9a97b fix: remove roles from budgeting workspace 2026-07-02 04:25:22 +05:30
Shllokkk
48aef307f9 fix: surface create payment entries as primary action on row selection 2026-07-02 02:19:38 +05:30
Mihir Kandoi
e5569f681a fix: classify repost recovery by exception type, not traceback string
repost() decided whether a failed Repost Item Valuation was recoverable
(re-queue as "In Progress") or permanently "Failed" by string-matching the
traceback for "timeout" or MariaDB's "Deadlock found". On Postgres a deadlock
surfaces as "deadlock detected" / "could not serialize access" and matches
neither, so a retriable deadlock was marked Failed and never re-queued -- the
scheduler only re-picks Queued/In Progress entries.

Classify by isinstance(e, RecoverableErrors) instead, the same tuple already
used to gate the error email. This covers deadlocks and lock/query timeouts on
both engines (frappe raises QueryDeadlockError / QueryTimeoutError uniformly)
and the advisory-lock repost gate's own QueryTimeoutError, which previously
recovered only because its class name incidentally contains "timeout".
2026-07-02 00:52:20 +05:30
Nabin Hait
145a0b154e fix: apply item and work_order filters in Process Loss Report 2026-07-02 00:34:36 +05:30
Mihir Kandoi
e99be23b57 Merge pull request #56696 from mihir-kandoi/pg/index-search
perf(postgres): partial/covering indexes + trigram item search
2026-07-02 00:23:18 +05:30
Nabin Hait
5cc866e840 Merge pull request #56737 from frappe/chore/test-bom-operations-time
test: BOM Operations Time report coverage
2026-07-02 00:21:54 +05:30
Mihir Kandoi
9e5b492db1 fix: tighten repost gate timeout, key, and scope (review)
- REPOST_LOCK_TIMEOUT 600 -> 300s: a contended waiter re-queues and frees its
  long-queue worker slot sooner instead of pinning it for up to 10 minutes
  (still well under the 1800s repost job timeout).
- Collision-free lock key: pass a ("stock_repost", item, warehouse) tuple so a
  colon in item_code/warehouse can't map two distinct pairs onto one lock.
- Document that the gate is repost-vs-repost only; the synchronous
  repost_current_voucher submit path is deliberately left ungated (gating a live
  submit behind a background repost would be a worse regression).
2026-07-02 00:20:15 +05:30
Nabin Hait
4cc2902b99 Merge pull request #56735 from frappe/chore/test-process-loss-report
test: Process Loss Report report coverage
2026-07-02 00:20:11 +05:30
Nabin Hait
b09889643f test: don't override tearDown; rely on ERPNextTestSuite rollback 2026-07-02 00:04:13 +05:30
Nabin Hait
4e88157ed7 test: stock raw materials before manufacture to avoid negative stock in CI 2026-07-02 00:02:50 +05:30
Mihir Kandoi
5e1296a0b9 perf(postgres): partial/covering indexes + trigram item search
Postgres-guarded on_doctype_update indexes: partial WHERE is_cancelled=0 + covering INCLUDE on GL Entry/SLE and Serial and Batch Bundle/Entry, and pg_trgm GIN on Item item_code/item_name (~128x faster LIKE search at scale). No-ops on MariaDB. Requires frappe framework support.
2026-07-02 00:00:37 +05:30
Nabin Hait
7ccd729cc5 Merge pull request #56732 from frappe/chore/test-downtime-analysis
test: Downtime Analysis report coverage
2026-07-01 23:57:38 +05:30
Nabin Hait
0aed70153b Merge pull request #56719 from frappe/chore/test-delivered-items-to-be-billed
test: Delivered Items To Be Billed report coverage
2026-07-01 23:54:45 +05:30
Nabin Hait
bdd6a63556 Merge pull request #56718 from frappe/chore/test-received-items-to-be-billed
test: Received Items To Be Billed report coverage
2026-07-01 23:54:37 +05:30
Mihir Kandoi
e51ffb1bf1 Merge pull request #56695 from mihir-kandoi/pg/recursive-cte
perf: use recursive CTEs for BOM and Task tree traversal
2026-07-01 23:53:39 +05:30
Nabin Hait
52d5085360 Merge pull request #56723 from frappe/chore/test-share-balance
fix: Share Balance respects the as-on date + test coverage
2026-07-01 23:52:15 +05:30
Nabin Hait
1969c9ca47 Merge pull request #56721 from frappe/chore/test-payment-period-based-on-invoice-date
fix: Payment Period ages by payment period + test coverage
2026-07-01 23:51:42 +05:30
Mihir Kandoi
83278d6f3b Merge pull request #56741 from mihir-kandoi/fix/multiple-variant-dialog-numeric
fix(item): rework multiple variant dialog for large numeric ranges
2026-07-01 23:43:50 +05:30
Mihir Kandoi
d4da9a3d7d 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>
2026-07-01 23:37:32 +05:30
Mihir Kandoi
99152b8300 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>
2026-07-01 23:28:03 +05:30
Mihir Kandoi
4afbd4d3d9 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>
2026-07-01 23:27:51 +05:30
Nabin Hait
e3e62a2211 Merge pull request #56716 from frappe/chore/test-voucher-wise-balance
test: Voucher-wise Balance report coverage
2026-07-01 22:55:12 +05:30
Nabin Hait
8b7780d494 test: add coverage for Exponential Smoothing Forecasting report 2026-07-01 22:54:38 +05:30
Nabin Hait
c38363c16d test: add coverage for Production Plan Summary report 2026-07-01 22:54:31 +05:30
Nabin Hait
304a247dc8 test: add coverage for BOM Operations Time report 2026-07-01 22:54:23 +05:30
Nabin Hait
8abef22a49 Merge pull request #56715 from frappe/chore/test-invalid-ledger-entries
fix: Invalid Ledger Entries account filter + test coverage
2026-07-01 22:54:22 +05:30
Nabin Hait
75ba81c79a test: add coverage for Cost of Poor Quality Report report 2026-07-01 22:54:16 +05:30
Nabin Hait
222842b7d1 test: add coverage for Process Loss Report report 2026-07-01 22:54:09 +05:30
Nabin Hait
376a5a2aee test: add coverage for BOM Variance Report report 2026-07-01 22:54:02 +05:30
Nabin Hait
47ee1d126d test: add coverage for Work Order Consumed Materials report 2026-07-01 22:53:54 +05:30
Nabin Hait
e179100afd Merge pull request #56714 from frappe/chore/test-purchase-invoice-trends
test: Purchase Invoice Trends report coverage
2026-07-01 22:52:16 +05:30
Nabin Hait
44aa01b115 Merge pull request #56713 from frappe/chore/test-sales-invoice-trends
test: Sales Invoice Trends report coverage
2026-07-01 22:51:47 +05:30
Nabin Hait
1a8ef852f1 test: add coverage for Downtime Analysis report 2026-07-01 22:49:01 +05:30
Nabin Hait
e0bf3713ea test: add coverage for Quality Inspection Summary report 2026-07-01 22:48:53 +05:30
Nabin Hait
eadaf37606 test: add coverage for BOM Explorer report 2026-07-01 22:48:43 +05:30
Nabin Hait
baae9bfb22 test: add coverage for Production Analytics report 2026-07-01 22:48:35 +05:30
Nabin Hait
4f3dcd9e39 test: add coverage for Job Card Summary report 2026-07-01 22:48:27 +05:30
Nabin Hait
7a9e901e5e test: add coverage for Work Order Summary report 2026-07-01 22:48:13 +05:30
Mihir Kandoi
bb184f90a7 perf: serialize concurrent reposts with an advisory lock
Wraps per-(item, warehouse) reposting in repost_future_sle with a session-level advisory lock in front of the existing for-update row locks -- an outer gate that turns lock-order deadlocks into an orderly wait. Postgres/MariaDB only; nullcontext elsewhere. Row locks still enforce correctness. Requires frappe advisory_lock.
2026-07-01 22:11:43 +05:30
Mihir Kandoi
b2ad93be81 perf: use recursive CTEs for BOM and Task tree traversal
Reimplements get_ancestor_boms, BOM.traverse_tree, Task.check_recursion and the BOM Explorer report as single recursive-CTE queries (frappe.qb recursive=True), replacing query-per-node walks (N->1). Task cycle detection now catches cycles at any depth (was capped at 15 nodes). Requires the framework recursive-CTE support (frappe#40464).
2026-07-01 22:10:57 +05:30
Mihir Kandoi
65cb89cc40 Merge pull request #56552 from aerele/fix-quotation-conversion-rate-from-customer
fix: set conversion_rate on quotation created from customer
2026-07-01 21:46:28 +05:30
Mihir Kandoi
26a646aae5 Merge pull request #56701 from aerele/fix/pick-list-status-issue
feat(stock): support partial transfer from pick list
2026-07-01 21:45:17 +05:30
Mihir Kandoi
7d5efaf124 Merge pull request #56670 from aerele/fix/pick-list-wo-status-not-started
fix: recompute transferred qty before deciding work order status
2026-07-01 21:44:35 +05:30
Nabin Hait
0e8b152c68 fix: avoid double-counting the total in accumulated Consolidated Financial Statement 2026-07-01 21:26:04 +05:30
Nabin Hait
028cc2cf49 fix: age payments by the payment period (payment date - invoice date) 2026-07-01 21:23:58 +05:30
Nabin Hait
249d519d02 fix: compute Share Balance as-on the selected date 2026-07-01 21:20:57 +05:30
Nabin Hait
e9aac23913 fix: filter Billed Items To Be Received by invoice name, not per_received 2026-07-01 21:18:01 +05:30
Nabin Hait
f4088d48a1 fix: accept a scalar account filter in Invalid Ledger Entries report 2026-07-01 21:16:20 +05:30
Nabin Hait
2c3285286c test: add coverage for Consolidated Financial Statement report 2026-07-01 21:13:21 +05:30
Nabin Hait
8e560f1d1c test: add coverage for Share Ledger report 2026-07-01 21:13:12 +05:30
Nabin Hait
02460b4684 test: add coverage for Share Balance report 2026-07-01 21:13:02 +05:30
Nabin Hait
2a1461c754 test: add coverage for Bank Clearance Summary report 2026-07-01 21:08:05 +05:30
Nabin Hait
ee8e6e806f test: add coverage for Payment Period Based On Invoice Date report 2026-07-01 21:07:56 +05:30
Nabin Hait
cccfdc72c9 test: add coverage for Billed Items To Be Received report 2026-07-01 21:07:45 +05:30
Nabin Hait
196482348d test: add coverage for Delivered Items To Be Billed report 2026-07-01 21:07:31 +05:30
Nabin Hait
237605889f test: add coverage for Received Items To Be Billed report 2026-07-01 21:07:21 +05:30
Nabin Hait
293f737e4a test: add coverage for Calculated Discount Mismatch report 2026-07-01 21:02:36 +05:30
Nabin Hait
38ebfd7bd6 test: add coverage for Voucher-wise Balance report 2026-07-01 21:02:29 +05:30
Nabin Hait
9ec2945e6e test: add coverage for Invalid Ledger Entries report 2026-07-01 21:02:21 +05:30
Nabin Hait
cb9ea22b6f test: add coverage for Purchase Invoice Trends report 2026-07-01 21:02:12 +05:30
Nabin Hait
8aec16376e test: add coverage for Sales Invoice Trends report 2026-07-01 21:02:02 +05:30
Nabin Hait
7bc121e308 Merge pull request #56520 from frappe/chore/test-incorrect-serial-and-batch-bundle
test: Incorrect Serial and Batch Bundle report coverage
2026-07-01 20:54:20 +05:30
Nabin Hait
0f65004626 Merge pull request #56544 from frappe/chore/test-stock-and-account-value-comparison
test: Stock and Account Value Comparison report coverage
2026-07-01 20:30:25 +05:30
Nabin Hait
1fdc3875b6 Merge pull request #56513 from frappe/chore/test-warehouse-wise-stock-balance
test: Warehouse Wise Stock Balance report coverage
2026-07-01 20:30:12 +05:30
Nabin Hait
bea8c7bea2 Merge pull request #56507 from frappe/chore/budget-variance-report-test-coverage
test: Budget Variance report value coverage
2026-07-01 20:29:55 +05:30
Nabin Hait
4ea9125902 Merge pull request #56506 from frappe/chore/stock-ledger-test-coverage
test: Stock Ledger report coverage
2026-07-01 20:29:06 +05:30
Nabin Hait
ef877c4001 Merge pull request #56505 from frappe/chore/item-wise-purchase-history-test-coverage
test: Item-wise Purchase History report coverage
2026-07-01 20:28:39 +05:30
Nabin Hait
bb0a46db9e Merge pull request #56504 from frappe/chore/item-wise-sales-history-test-coverage
test: Item-wise Sales History report coverage
2026-07-01 20:28:29 +05:30
Nabin Hait
ae155e916a Merge pull request #56524 from frappe/chore/test-stock-ledger-variance
test: Stock Ledger Variance report coverage
2026-07-01 20:27:51 +05:30
Nabin Hait
cd6a8952e6 Merge pull request #56519 from frappe/chore/test-incorrect-balance-qty-after-transaction
test: Incorrect Balance Qty After Transaction report coverage
2026-07-01 20:27:33 +05:30
Nabin Hait
726edab495 Merge pull request #56518 from frappe/chore/test-fifo-queue-vs-qty-after-transaction-comparison
test: FIFO Queue vs Qty After Transaction Comparison report coverage
2026-07-01 20:27:02 +05:30
Nabin Hait
343557cf24 Merge pull request #56530 from frappe/chore/test-item-prices
test: Item Prices report coverage
2026-07-01 20:26:36 +05:30
Nabin Hait
bc28cfe182 Merge pull request #56521 from frappe/chore/test-incorrect-serial-no-valuation
test: Incorrect Serial No Valuation report coverage
2026-07-01 20:26:18 +05:30
Nabin Hait
f47141a3b7 test: flag an actual balance-qty variance in Stock Ledger Variance 2026-07-01 20:15:32 +05:30
Nabin Hait
ea5be1f7a5 fix: minor fix
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-07-01 20:12:42 +05:30
Nabin Hait
4960ca12fa Merge pull request #56532 from frappe/chore/test-itemwise-recommended-reorder-level
test: Itemwise Recommended Reorder Level report coverage
2026-07-01 20:11:44 +05:30
Nabin Hait
b52a8f5a77 test: apply ruff formatting 2026-07-01 19:34:29 +05:30
Nabin Hait
86eff05303 test: drop unused variable and apply ruff formatting 2026-07-01 19:34:03 +05:30
Nabin Hait
bd874d09ef test: drop unused variable and apply ruff formatting 2026-07-01 19:33:00 +05:30
Nabin Hait
04c710bec2 Merge pull request #56508 from frappe/chore/profitability-analysis-test-coverage
test: Profitability Analysis report coverage
2026-07-01 19:29:26 +05:30
Nabin Hait
875fc72842 test: flag an SLE whose balance is out of sync with the FIFO queue 2026-07-01 19:29:16 +05:30
ervishnucs
e4d6c0854b fix: remove redundant conversion_rate 2026-07-01 19:28:38 +05:30
Nabin Hait
527765001c test: flag a serial with mismatched in/out valuation 2026-07-01 19:27:56 +05:30
Nabin Hait
bd9aa8db68 Merge pull request #56509 from frappe/chore/gross-net-profit-report-test-coverage
test: Gross and Net Profit report coverage
2026-07-01 19:27:09 +05:30
Nabin Hait
7af8ca58d2 Merge pull request #56534 from frappe/chore/test-purchase-receipt-trends
test: Purchase Receipt Trends report coverage
2026-07-01 19:26:29 +05:30
Nabin Hait
adbd8276cf Merge pull request #56535 from frappe/chore/test-delivery-note-trends
test: Delivery Note Trends report coverage
2026-07-01 19:26:12 +05:30
Nabin Hait
beb2974317 test: flag an unlinked (orphan) serial and batch bundle 2026-07-01 19:25:54 +05:30
Nabin Hait
e5c0bd7931 Merge pull request #56516 from frappe/chore/test-product-bundle-balance
test: Product Bundle Balance report coverage
2026-07-01 19:25:40 +05:30
Nabin Hait
705f308ef7 Merge pull request #56514 from frappe/chore/test-total-stock-summary
test: Total Stock Summary report coverage
2026-07-01 19:25:30 +05:30
Nabin Hait
f8ce46f127 Merge pull request #56517 from frappe/chore/test-item-wise-consumption
test: Item-wise Consumption report coverage
2026-07-01 19:24:43 +05:30
Nabin Hait
039314c306 test: make Item Prices tests deterministic (fresh items, label-based columns) 2026-07-01 19:22:30 +05:30
Nabin Hait
f42198fb3c Merge pull request #56522 from frappe/chore/test-negative-batch-report
test: Negative Batch Report report coverage
2026-07-01 19:21:59 +05:30
Nabin Hait
7139639e77 Merge pull request #56525 from frappe/chore/test-stock-qty-vs-batch-qty
test: Stock Qty vs Batch Qty report coverage
2026-07-01 19:21:04 +05:30
Nabin Hait
f4ad1541bd Merge pull request #56529 from frappe/chore/test-warehouse-wise-item-balance-age-and-value
test: Warehouse Wise Item Balance Age and Value report coverage
2026-07-01 19:20:51 +05:30
Nabin Hait
49ecab6514 Merge pull request #56540 from frappe/chore/test-serial-no-and-batch-traceability
test: Serial No and Batch Traceability report coverage
2026-07-01 19:18:56 +05:30
Nabin Hait
3104369d79 Merge pull request #56542 from frappe/chore/test-cogs-by-item-group
test: COGS By Item Group report coverage
2026-07-01 19:18:42 +05:30
Nabin Hait
116b7bf672 fix: minor fix
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-01 19:18:17 +05:30
Nabin Hait
7f44583a94 Merge remote-tracking branch 'origin/develop' into chore/test-stock-and-account-value-comparison
# Conflicts:
#	erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
2026-07-01 18:57:43 +05:30
ruthra kumar
c482a3b699 Merge pull request #56706 from ruthra-kumar/rename_synced_to_snapshot
refactor: rename synced to snapshot report
2026-07-01 17:42:58 +05:30
Mihir Kandoi
bfe01476be Merge pull request #56708 from frappe/fix-redundant-cast
chore: remove redundant type cast
2026-07-01 17:35:18 +05:30
ruthra kumar
981e90e4da refactor: rename feature toggle in report master 2026-07-01 17:32:33 +05:30
Mihir Kandoi
9cf356f6f5 chore: remove redundant type case 2026-07-01 17:23:19 +05:30
Nikhil Kothari
bbc4d2ccab feat: capture user persona during setup (#56705) 2026-07-01 11:51:15 +00:00
Mihir Kandoi
f493417c3d Merge pull request #56702 from mihir-kandoi/fix/normalize-ctx-input-py314
fix: keep normalize_ctx_input's ctx annotation on Python 3.14
2026-07-01 17:18:54 +05:30
Mihir Kandoi
8271b29e42 style: apply ruff formatter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:04:11 +05:30
Sudharsanan11
fad904d68b 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-01 16:54:57 +05:30
Nabin Hait
9738228d9c Merge pull request #56526 from frappe/chore/test-stock-qty-vs-serial-no-count
test: Stock Qty vs Serial No Count report coverage
2026-07-01 16:46:30 +05:30
pandiyan
d072909451 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-01 16:41:15 +05:30
Mihir Kandoi
c00e5050cc Merge pull request #56703 from mihir-kandoi/fix/supplier-scorecard-recursion
fix: prevent max recursion on supplier scorecard save
2026-07-01 16:34:32 +05:30
Mihir Kandoi
e6f8f8f7e9 refactor: use frappe._dict in importers of ItemDetailsCtx
Extend the boundary rule to callers: non-decorated code that built or
annotated with ItemDetailsCtx now uses frappe._dict directly, and drops
the now-unused import. asset_capitalization keeps ItemDetailsCtx for its
own normalize_ctx_input-decorated functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:30:16 +05:30
Mihir Kandoi
9406ec49de refactor: use frappe._dict directly in non-decorated helpers
ItemDetailsCtx signals the normalize_ctx_input boundary, so keep it only
on the decorator and the ctx param of decorated functions. Every other
annotation/constructor in non-decorated code becomes plain frappe._dict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:27:42 +05:30
Mihir Kandoi
603404775b fix: reset in_rescore flag after re-save
Ensure the recursion guard only applies to the nested save() and is cleared
afterwards, so a later save() on the same doc instance still creates periods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:19:52 +05:30
Mihir Kandoi
f26cb793b1 fix: restore | dict + normalization on non-decorated helpers
get_item_price is an internal, non-decorated helper: the "| dict" and
"pctx = frappe._dict(pctx)" were load-bearing (callers may pass a plain
dict; the body does attribute access). Restore both. Also restore the
"| dict" on set_valuation_rate/update_party_blanket_order out params
(these are not normalize_ctx_input-decorated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:19:07 +05:30
Mihir Kandoi
31e2d4ac5a fix: prevent max recursion on supplier scorecard save
on_update() called self.save(), which re-enters on_update() via
run_post_save_methods(), recursing indefinitely when make_all_scorecards()
keeps returning newly created periods. Guard the re-save with an in_rescore
flag so the nested on_update() short-circuits, while still running the full
validate() once to refresh score and standings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:15:59 +05:30
Mihir Kandoi
6eeadbdbef fix: keep normalize_ctx_input's ctx annotation on Python 3.14
Python 3.14 (PEP 649/749) replaced "__annotations__" with "__annotate__"
in functools.WRAPPER_ASSIGNMENTS. normalize_ctx_input excluded only
"__annotations__" when wrapping, so functools.wraps copied the wrapped
function's __annotate__ and the wrapper's permissive ctx annotation
(_dict | Document | dict | str) was overwritten by the narrow
ItemDetailsCtx | str. Now that Frappe casts whitelisted args via
typing_validations, a dict ctx failed the isinstance-only frappe._dict
check and raised FrappeTypeError. Exclude "__annotate__" too.

Cleanup while here:
- Merge the three identical frappe._dict aliases (ItemDetails,
  ItemDetailsCtx, ItemPriceCtx) into ItemDetailsCtx.
- Drop the now-redundant "| str" from decorated signatures; the
  decorator's wrapper union is what typing_validations enforces.
- Decorate get_batch_based_item_price with normalize_ctx_input instead
  of a manual parse_json, renaming its arg pctx -> ctx (JS caller
  updated) so a dict/string payload is normalized to frappe._dict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:07:45 +05:30
Nabin Hait
497ca14747 test: detect a real stock/account value mismatch in comparison report 2026-07-01 15:55:33 +05:30
Nabin Hait
a2f8063804 Merge pull request #56543 from frappe/chore/test-landed-cost-report
test: Landed Cost Report report coverage
2026-07-01 15:54:32 +05:30
rohitwaghchaure
adae0bd732 feat: weekly auto-repost of incorrect stock valuation entries (#56637) 2026-07-01 15:47:46 +05:30
Sudharsanan11
a1daad8d4f test(stock): add test for partial transfer status from pick list 2026-07-01 15:44:35 +05:30
Sudharsanan11
27d5165755 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-01 15:44:26 +05:30
sokumon
088b8ff69b fix: remove roles from home workspace 2026-07-01 14:22:43 +05:30
rohitwaghchaure
58e5755780 fix: manufacturing variance for standard cost valuation (#56684) 2026-07-01 14:04:14 +05:30
Mihir Kandoi
04cbb5da75 Merge pull request #56691 from mihir-kandoi/pg-ci-warmup-test-data
ci(postgres): warm up test data before baking the datadir
2026-07-01 13:55:38 +05:30
Nikhil Kothari
300471da12 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
2026-07-01 13:41:37 +05:30
Mihir Kandoi
3c067502f3 Merge pull request #56688 from mihir-kandoi/pg-greptile-over-rollback
ci(postgres): flag the over-broad-rollback trap in txn-abort review
2026-07-01 13:34:41 +05:30
Mihir Kandoi
63325cb976 ci(postgres): match MariaDB test job name (drop "(PG)")
Both server-test workflows now name the test job "Python Unit Tests" so the
check appears under the same name regardless of engine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:32:53 +05:30
sokumon
3b25878d71 fix: remove text from projects workspace 2026-07-01 13:29:25 +05:30
ruthra kumar
ba7b6a47c5 refactor: rename execute_synced_report to execute_snapshot_report
Match the framework rename of the standard report entry point in the
trial balance, P&L, balance sheet, and general ledger reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:27:21 +05:30
Mihir Kandoi
e36e6bbb96 ci(postgres): warm up test data before baking the datadir
Mirror frappe/erpnext#56655 for the Postgres CI. Run the
bootstrap_test_data module in the setup job while Postgres is still up, so
the BootStrapTestData records are baked into the PGDATA artifact every shard
hydrates from — the shards start on already-warmed data instead of each
building it.

Unlike the MariaDB step, no `su -m` wrapper: the Postgres CI is
GitHub-hosted ubuntu-latest running as the runner user directly, matching
its own "Run Tests" step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:22:30 +05:30
Mihir Kandoi
b6382dce52 ci(postgres): add the return-contract note to the over-rollback bullet
Mirror the config.json guidance in POSTGRES_COMPATIBILITY.md: when scoping a rollback, keep the function's
success/None return contract -- don't return the doc that was just rolled back. (greptile #56688)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:21:18 +05:30
Nikhil Kothari
26583ae357 chore: update dependencies in banking app (#56685)
chore: update deps in banking app
2026-07-01 07:42:52 +00:00
Mihir Kandoi
06fb20d02d ci(greptile): flag over-broad full rollbacks in catch-and-continue handlers
Mirror the POSTGRES_COMPATIBILITY.md rule into the greptile instructions: prefer a scoped savepoint over a
full frappe.db.rollback() when recovering a poisoned txn; 'owns the txn' is not safe in a loop handler; and
keep the success/None return contract when scoping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:05:57 +05:30
Mihir Kandoi
c976b86714 ci(postgres): teach the parity guide the over-broad-rollback trap
Recovering a poisoned Postgres txn with a full frappe.db.rollback() discards rows the handler already
created before the failure -- which MariaDB keeps (no statement-abort) -- so it's a silent MariaDB
regression. 'Owns the txn' does not make a full rollback safe in a loop handler. Document the safe cases
(re-raise / single op / atomic batch) and the per-iteration/per-record savepoint alternative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:05:56 +05:30
Mihir Kandoi
a98474cab0 Merge pull request #56686 from mihir-kandoi/pg-d3-serial-no-fixture-case
test(stock): fix Available Serial No fixture item-code case for Postgres
2026-07-01 13:04:01 +05:30
Mihir Kandoi
9f229d614e test(stock): fix Available Serial No fixture item-code case for Postgres
setUp creates/receives/delivers the item as '_Test Item with Serial No' (lowercase w) but the report
filter used '_Test Item With Serial No' (capital W). MariaDB's case-insensitive collation resolved it,
but Postgres (case-sensitive) matched no item, so the report's 'if items:' guard dropped the item
filter and returned serial-no rows for every item in the window -- an order-dependent, flaky count on
Postgres. Align the filter to the created item's case (a no-op on MariaDB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:51:54 +05:30
Mihir Kandoi
cb1642c7f6 Merge pull request #56683 from mihir-kandoi/pg-c1-txn-abort-followup
fix: scope three more Postgres txn-abort savepoints (fiscal year, Plaid sync, CRM customer)
2026-07-01 12:44:19 +05:30
Mihir Kandoi
59b49120b7 fix(crm): keep returning None from create_customer on a linking failure
Preserve the pre-existing contract: create_customer returned None when contact/address linking failed.
The savepoint fix kept the Customer (good) but started returning its name in that case, so a CRM caller
treating a non-None return as full success could skip its retry/error handling. Return None on a linking
failure while still keeping the Customer. (greptile #56683)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:28:36 +05:30
Mihir Kandoi
76b31d9269 fix(crm): scope create_customer rollback so a contact/address failure keeps the Customer
create_customer wrapped customer.insert() + create_contacts() + create_address() in one try whose except
did a full frappe.db.rollback(), so a failure while linking contacts/address discarded the Customer just
created (MariaDB kept it pre-migration). Split the try: the customer insert keeps its full rollback (safe
-- nothing precedes it), and contact/address linking runs under a savepoint so its failure rolls back only
the links, preserving the Customer and healing the Postgres txn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:55:42 +05:30
Mihir Kandoi
c58a4026a7 fix(integrations): per-transaction savepoint in Plaid sync_transactions
new_bank_transaction inserts+submits Bank Transactions in a loop within one transaction. On a failed
insert/submit, Postgres poisons the transaction so the except's log_error dies with InFailedSqlTransaction;
MariaDB keeps the Bank Transactions synced before the failure. A full rollback would discard those on
MariaDB too, so wrap each iteration in a savepoint + rollback(save_point=) and re-raise -- preserves
MariaDB's partial-sync behaviour and heals the Postgres txn. The sibling handlers add_institution /
add_bank_accounts were already fixed; this closes the third.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:55:41 +05:30
Mihir Kandoi
b926b846b1 fix(accounts): savepoint auto_create_fiscal_year loop to survive a duplicate year on Postgres
The daily scheduler loops creating next-year Fiscal Years (autoname=field:year). A duplicate-year
INSERT aborts the statement; on Postgres that poisons the whole transaction, so the next iteration's
get_doc/insert dies with InFailedSqlTransaction. MariaDB statement-rolls-back and continues. Wrap each
iteration in a savepoint + rollback(save_point=) in the DuplicateEntryError branch -- a strict no-op on
MariaDB (same INSERT, same skip), recovers the txn on Postgres.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:55:40 +05:30
Mihir Kandoi
36e0b71602 Merge pull request #56681 from mihir-kandoi/pg-b4-orderby-tiebreakers
fix: add unique tiebreakers to ORDER BY … LIMIT 1 picks for MariaDB↔Postgres parity
2026-07-01 09:30:17 +05:30
Mihir Kandoi
8f3eb6cb31 fix(selling): tie-break POS customer contact pick for cross-engine parity
The contact lookup orders only by is_primary_contact desc then takes contacts[0]; contacts commonly
tie (the no-primary case), so MariaDB and Postgres could pick a different contact. Add a parent
(contact name) tiebreaker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:10:19 +05:30
Mihir Kandoi
7a798dcba9 fix(stock): tie-break pick-list lookup in update_packed_item_with_pick_list_info
The Pick List Item get_value orders only by qty desc; a pick list can hold multiple rows for the same
SO item split across warehouses/batches/serials that tie on qty, so MariaDB and Postgres could stamp a
different warehouse/batch/serial onto the packed item. Add a name tiebreaker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:10:18 +05:30
Mihir Kandoi
813dcca29a fix(accounts): tie-break open Payment Request ordering for cross-engine parity
get_open_payment_requests_for_references orders by Coalesce(transaction_date, creation); when
transaction_date is set the coalesce never falls back to creation, so PRs sharing a transaction_date
have no tiebreaker and MariaDB/Postgres can allocate a different PR first. Append creation, name keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:10:18 +05:30
Mihir Kandoi
7f6a234cf7 fix(stock): pin get_item_price tie-break so MariaDB and Postgres agree
get_item_price ORDER BYs valid_from/batch_no/uom/party then LIMIT 1 with no unique key. Two Item
Price rows tied on all of those but differing price_list_rate would be picked arbitrarily -- MariaDB
and Postgres can return a different rate. Append a name tiebreaker; for exact ties MariaDB's pick was
already undefined, so its output is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:10:16 +05:30
Diptanil Saha
85853fce12 Merge pull request #56678 from diptanilsaha/fix/gross_profit_debit_note
fix(gross_profit): correct GP calculation for rate adjustment debit notes
2026-07-01 08:32:05 +05:30
diptanilsaha
17ef5d6034 test(gross_profit): added test cases for rate adjustment entry 2026-07-01 08:17:42 +05:30
diptanilsaha
b9f330a158 fix: gross profit calculation with rate adjustment entries 2026-07-01 08:02:45 +05:30
Shllokkk
35de9deb0a 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
2026-07-01 06:54:26 +05:30
Mihir Kandoi
94a0c102a3 Merge pull request #56446 from aerele/fix/support-#72225
fix: support quality inspection for stock entry by purpose
2026-06-30 22:37:26 +05:30
Sudharsanan11
847fd8aa33 fix(stock): exclude consumption from outgoing quality inspection
The QI-by-purpose check required an inspection on every outgoing
(s_warehouse) row for any purpose that was not incoming. Material
Consumption for Manufacture rows are source-only and the Work Order
mapper copies inspection_required from the BOM, so this silently
blocked submission. An inspection_required BOM inspects the finished
good, not each consumed raw material.

Replace the "anything not incoming" fallthrough with an explicit
QI_OUTGOING_PURPOSES allow-list (mirrored in transaction.js) so a new
purpose cannot silently start requiring a QI. Consumption and Return
Raw Material to Customer now need no QI; Issue, Transfer, Transfer for
Manufacture, Send to Subcontractor, Subcontracting Delivery and
Disassemble keep their outgoing checks. Scope item_query to the same
set and add a regression test.
2026-06-30 21:32:30 +05:30
Mihir Kandoi
5afabb089d Merge pull request #56665 from mihir-kandoi/pg-greptile-audit-learnings
ci(greptile): DISTINCT row-count trap + refactor/conversion row-set faithfulness
2026-06-30 21:08:43 +05:30
Shllokkk
6425b9afaf Merge pull request #56661 from Shllokkk/fix-bulk-transaction-method
fix: handle None args in transaction_processing
2026-06-30 21:02:05 +05:30
Mihir Kandoi
f560767eb0 ci(postgres): include §6 in the How-to-review closing summary
The closing paragraph named only the §2/§3 semantic divergences as static-checker-invisible;
§6 (refactor/conversion row-set changes) is equally invisible and belongs there too. (greptile nit.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:58:52 +05:30
Mihir Kandoi
e79c24791f ci(greptile): flag the DISTINCT row-count trap and refactor-smuggled row-set changes
Mirror the two new POSTGRES_COMPATIBILITY.md rules into the greptile instructions so the bot flags
them on changed queries: (1) adding an ORDER BY column to a SELECT DISTINCT grows the distinct key
and the MariaDB row count unless it is functionally dependent; (2) a refactor / raw-frappe.db.sql->qb
conversion can silently change the WHERE/row set on both engines -- review the predicate, not just
the query shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:41:35 +05:30
Mihir Kandoi
a26329b2a0 ci(postgres): teach the parity guide the DISTINCT row-count trap + refactor faithfulness
Two review lessons from the post-merge net-diff/whole-repo re-audit of the SQL-dialect classes:

- Section 3 (row-count trap) now covers SELECT DISTINCT too: adding the ORDER BY column to the
  select to satisfy Postgres grows the DISTINCT key and changes the MariaDB row count when the
  column is not single-valued per distinct row -- sort in Python instead.
- New section 6: a 'refactor' / raw-SQL->qb conversion is not automatically 1:1. Diff the
  WHERE/predicate and the resulting row set, not just the SELECT shape -- a conversion that widens
  a filter (e.g. posting_datetime > X gaining an OR (== X AND creation > ...) branch under a
  sql->qb refactor) changes the rows touched on both engines and hides under a refactor label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:41:34 +05:30
Mihir Kandoi
78a64cd79b Merge pull request #56662 from mihir-kandoi/st72149
fix: use correct variable to fetch valuation method
2026-06-30 20:33:48 +05:30
Mihir Kandoi
1492c9fbc3 fix: use correct variable to fetch valuation method 2026-06-30 20:23:14 +05:30
Diptanil Saha
5b3e6e4714 Revert "chore: remove unused whitelisted method from project" (#56660) 2026-06-30 20:12:03 +05:30
Shllokkk
0db4af22e0 fix: handle None args in transaction_processing 2026-06-30 20:11:28 +05:30
Venkatesh
3b0e1fbb79 fix: remove translation for filter (#56629)
Co-authored-by: SowmyaArunachalam <sowmyaarunachalam57@gmail.com>
2026-06-30 14:52:11 +02:00
Mihir Kandoi
1c92dac274 Merge pull request #56450 from aerele/fix/support-#70387
fix(selling): update sales order per billed on credit note submission
2026-06-30 17:54:09 +05:30
ruthra kumar
d389a03c15 Merge pull request #56655 from ruthra-kumar/bootstrap_test_data_in_warmed_db
ci: warmup test data along with DB
2026-06-30 17:44:45 +05:30
ruthra kumar
dcdbf9df17 ci: warmup test data along with DB 2026-06-30 17:30:36 +05:30
sokumon
55afd95b20 fix: remove duplicate links from export 2026-06-30 17:24:58 +05:30
sokumon
5a32866b93 chore: export more workspaces 2026-06-30 17:00:54 +05:30
rohitwaghchaure
b8be1c8efd refactor: frappe.db.sql to frappe.qb for update_qty_in_future_sle (#56609) 2026-06-30 15:40:10 +05:30
Nikhil Kothari
8447f551e7 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
2026-06-30 09:21:42 +00:00
Sudharsanan Ashok
6184c057db 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
2026-06-30 14:28:04 +05:30
Mihir Kandoi
3d7bcd1f6a Merge pull request #56630 from mihir-kandoi/pg-convergence-fixes
fix: Postgres transaction-abort savepoints + div0/tiebreaker convergence fixes
2026-06-30 13:40:07 +05:30
Sudharsanan11
710d0667fa test(selling): add test to validate the per billed after credit note submission 2026-06-30 13:03:07 +05:30
Mihir Kandoi
460bb9e5d0 fix(crm): scope create_address rollback to a savepoint (review)
create_address is a helper called by create_prospect/create_customer AFTER they insert the Prospect/Customer. Its full frappe.db.rollback() on an address-save failure rolled back the caller's just-inserted parent doc, then swallowed the exception, so the caller returned a Prospect/Customer name that no longer existed. Scope the rollback to savepoint('crm_create_address') so only the address work is undone; the parent doc survives and the failed address is just logged.
2026-06-30 13:01:32 +05:30
Sudharsanan11
1202e79a16 fix(stock): fix tests 2026-06-30 12:31:14 +05:30
Khushi Rawat
8dfabf0e19 Merge pull request #56569 from frappe/fix-asset-is-fully-depreciated-visibility
fix(asset): conditionally show Is Fully Depreciated field
2026-06-30 12:28:22 +05:30
Mihir Kandoi
a36065931d fix(telephony): scope link_existing_conversations rollback to a savepoint (review)
link_existing_conversations is the Contact after_insert hook; a full frappe.db.rollback() on a failed call_log.save() would discard the triggering Contact insert itself (and, in test mode, the whole unit of work). Savepoint the hook's DB work and roll back only to it.
2026-06-30 12:17:39 +05:30
Mihir Kandoi
bd57e43446 fix(setup): scope regional-tax-settings rollback to a savepoint (review)
from_detailed_data inserts tax templates/accounts before update_regional_tax_settings in the same transaction; a full frappe.db.rollback() on regional-setup failure discarded those templates while the wizard continued. Take a savepoint before the regional call and roll back only to it.
2026-06-30 12:17:38 +05:30
Mihir Kandoi
16a6a4913e fix(stock): log Material Request failure without the rolled-back doc (review)
After rollback(save_point=reorder_mr) discards the just-inserted Material Request, mr.log_error() left a dangling Error Log reference. Use frappe.log_error(title=...).
2026-06-30 12:17:37 +05:30
Mihir Kandoi
b0331f13f1 fix(accounts): log bank-entry failure without the rolled-back doc (review)
The savepoint rollback erases the just-inserted Bank Transaction row, so bank_transaction.log_error() created an Error Log pointing at a row that no longer exists. Use frappe.log_error(title=...) with no doc reference.
2026-06-30 12:17:36 +05:30
Mihir Kandoi
3a1b47435f Merge pull request #56621 from aerele/fix/support-72552
fix: set mr status to received when per_received is 100 even if per_o…
2026-06-30 11:10:31 +05:30
pandiyan
a3c5ef6aa3 fix: set mr status to received when per_received is 100 even if per_ordered < 100 2026-06-30 11:00:23 +05:30
MochaMind
5c17c7d285 fix: sync translations from crowdin (#56633)
* fix: Persian translations

* fix: Swedish translations
2026-06-29 23:20:06 +02:00
Mihir Kandoi
f41e8208d8 fix(crm): savepoint Email Campaign send loop (Postgres)
send_mail (called per campaign schedule in a loop) inserts a Communication via make(); on failure the except calls frappe.log_error with no rollback, raising InFailedSqlTransaction on Postgres and poisoning subsequent sends. Savepoint before make() + rollback(save_point=) before log_error. No-op on MariaDB.
2026-06-29 22:48:28 +05:30
Mihir Kandoi
4feb9f9910 fix(assets): savepoint per-entry depreciation posting (Postgres)
make_depreciation_entry posts a Journal Entry per schedule row in a loop; the except only stored the error, so the next row's je.save()/submit() ran on the Postgres-poisoned txn (InFailedSqlTransaction). Savepoint per iteration + rollback(save_point=) before storing the error; the final raise of the collected error is unchanged. No-op on MariaDB.
2026-06-29 22:48:27 +05:30
Mihir Kandoi
944eeb5921 fix(accounts): savepoint subscription-status update loop in Payment Entry (Postgres)
trigger_invoice_update_for_subscriptions loops invoices calling refresh_subscription_status (db_set/save); on failure the except calls frappe.log_error with no rollback, raising InFailedSqlTransaction on Postgres, and the next invoice runs in the poisoned txn. Savepoint per iteration + rollback(save_point=) before log_error. No-op on MariaDB.
2026-06-29 22:48:26 +05:30
Mihir Kandoi
f3785f10a2 fix(accounts): savepoint per-row merge in Ledger Merge (Postgres)
start_merge merges accounts in a loop; on failure it only rolled back when not in_test, so in tests a failed merge_account left the Postgres txn poisoned and the except log_error + the finally db_set(status) raised InFailedSqlTransaction. Wrap each row in savepoint('ledger_merge_row') and rollback to it unconditionally before log_error - this recovers the txn in both paths without the full rollback discarding the rest of the test transaction. Production still commits per successful merge, so the per-iteration savepoint rollback is equivalent to the prior full rollback. No-op on MariaDB.
2026-06-29 22:48:25 +05:30
Mihir Kandoi
6b0f3cd243 fix(crm): rollback before logging in Frappe CRM webhook handlers (Postgres)
create_prospect/create_address/create_customer insert docs and on failure call frappe.log_error with no rollback; on Postgres (untrusted external CRM webhook input) a failed insert poisons the txn so log_error raises InFailedSqlTransaction. Full frappe.db.rollback() before each log_error. No-op on MariaDB.
2026-06-29 22:45:22 +05:30
Mihir Kandoi
5110e7f0fd fix(accounts): rollback before log_error in deferred-accounting in_test branch (Postgres)
book_deferred_entries' make_gl_entries failure path: the else branch already rolls back before log_error, but the frappe.in_test branch ran doc.log_error then re-raised with no rollback -> on Postgres log_error hits InFailedSqlTransaction and masks the original error. Rollback before log_error in the in_test branch too. No-op on MariaDB.
2026-06-29 22:45:21 +05:30
Mihir Kandoi
c643fe5274 fix(manufacturing): rollback before marking BOM Creator failed (Postgres)
create_production_plan_bom (background job) save+submits BOMs in a loop; on failure the except runs self.db_set(status=Failed, error_log) with no rollback, raising InFailedSqlTransaction on Postgres so status is never set. Full frappe.db.rollback() at the top of the except. No-op on MariaDB.
2026-06-29 22:45:20 +05:30
Mihir Kandoi
790560ebf8 fix(stock): rollback before marking Stock Closing Entry failed (Postgres)
prepare_closing_stock_balance (background job) saves Stock Closing Balance rows + db_set status; on failure the except runs db_set('Failed')+log_error with no rollback, raising InFailedSqlTransaction on Postgres so the doc is never marked Failed and the job dies. Full frappe.db.rollback() before the handler's db_set. No-op on MariaDB.
2026-06-29 22:45:19 +05:30
Mihir Kandoi
44458b0ba5 fix(setup): rollback before logging in update_regional_tax_settings (Postgres)
Regional tax-template setup writes docs; on failure the except calls frappe.log_error with no rollback -> InFailedSqlTransaction on Postgres. Full rollback before log_error. No-op on MariaDB.
2026-06-29 22:42:49 +05:30
Mihir Kandoi
8c0b4a99cf fix(setup): rollback before logging in install_country_fixtures (Postgres)
Regional fixture setup writes docs; on failure the except calls frappe.log_error before frappe.throw with no rollback -> InFailedSqlTransaction on Postgres. Full rollback before log_error. No-op on MariaDB.
2026-06-29 22:42:48 +05:30
Mihir Kandoi
01811ccf85 fix(telephony): rollback before logging in call_log link_existing_conversations (Postgres)
The hook saves Call Logs in a loop; on failure the except calls frappe.log_error (INSERT) with no rollback, raising InFailedSqlTransaction on Postgres (it runs on every Contact create/update). Full frappe.db.rollback() before log_error. No-op on MariaDB.
2026-06-29 22:42:48 +05:30
Mihir Kandoi
09a3eb8509 fix(integrations): savepoint the Plaid bank-account update branch + rollback add_institution (Postgres)
add_bank_accounts hardened only the INSERT branch with savepoint('plaid_bank_account'); the parallel else/UPDATE branch ran log_error+throw after a failed existing_account.save() with no rollback -> InFailedSqlTransaction on Postgres (masking the friendly throw). Mirror the insert branch with savepoint('plaid_update_account')+rollback. Also add_institution's except log_error after a failed bank.insert() now rolls back first. No-op on MariaDB.
2026-06-29 22:42:47 +05:30
Mihir Kandoi
298df4d3aa fix(stock): savepoint per-company Material Request creation in reorder (Postgres)
create_material_request loops companies inserting+submitting a Material Request; the except calls mr.log_error (INSERT) with no rollback, raising InFailedSqlTransaction on Postgres in the scheduled reorder job, and the next company runs in the poisoned txn. Savepoint per iteration + rollback(save_point=) before log_error. No-op on MariaDB.
2026-06-29 22:38:02 +05:30
Mihir Kandoi
c97eac34bf fix(accounts): savepoint per-row bank entry in Bank Transaction upload (Postgres)
create_bank_entries loops rows inserting+submitting a Bank Transaction; on failure the except calls bank_transaction.log_error (INSERT) with no rollback, raising InFailedSqlTransaction on Postgres, and the next row runs in the poisoned txn. Savepoint per row + rollback(save_point=) before log_error. No-op on MariaDB.
2026-06-29 22:38:02 +05:30
Mihir Kandoi
4a572311bc fix(buying): insert default Supplier Scorecard records with ignore_if_duplicate (Postgres)
make_default_records inserted Scorecard Variable/Standing rows in a loop and swallowed DuplicateEntryError (frappe.NameError). On Postgres the failed insert poisons the txn so the next iteration's insert raises InFailedSqlTransaction. insert(ignore_if_duplicate=True) emits ON CONFLICT DO NOTHING, never poisoning the txn. No-op on MariaDB.
2026-06-29 22:38:01 +05:30
Mihir Kandoi
1dde2b5f1e fix(stock): savepoint repost loop in Stock Ledger Invariant Check (Postgres)
Same shape: the rows loop submits a Repost Item Valuation; a caught DuplicateEntryError poisons the Postgres txn so the next iteration's submit raises InFailedSqlTransaction. Savepoint + rollback(save_point=) before continue. No-op on MariaDB.
2026-06-29 22:38:00 +05:30
Mihir Kandoi
d7a81affc2 fix(stock): savepoint repost loop in Stock and Account Value Comparison (Postgres)
The item/warehouse loop submits a Repost Item Valuation; a DuplicateEntryError poisons the Postgres transaction, so the next iteration's .submit() raises InFailedSqlTransaction. MariaDB continues. Savepoint per iteration + rollback(save_point=) on the caught duplicate (mirrors repost_item_valuation:782). No-op on MariaDB.
2026-06-29 22:37:59 +05:30
Mihir Kandoi
91dae91769 fix(setup): deterministic tiebreaker in get_exchange_rate Currency Exchange lookup (Postgres)
get_exchange_rate orders Currency Exchange by 'date desc' LIMIT 1 with no unique tiebreaker. Currency Exchange autoname {date}-{from}-{to}-{purpose} allows multiple same-date rows (different purpose) for one currency pair; on the no-purpose-filter path all match, so MariaDB and Postgres can return a different exchange_rate for the same inputs. Add 'name desc' so both engines pick the same row. MariaDB row count unchanged.
2026-06-29 22:25:39 +05:30
Mihir Kandoi
8c03029f28 fix(controllers): guard return-rate division against a zero stock qty (Postgres)
get_rate_for_return builds Abs(stock_value_difference / actual_qty) for Sales/Delivery returns and passes it to get_value with no actual_qty filter. A matched Stock Ledger Entry with actual_qty=0 (a zero-qty repost / serial-batch row) makes Postgres raise 'division by zero' while MariaDB returns NULL. Wrap the divisor in NullIf(actual_qty, 0) so both engines return NULL. MariaDB output unchanged. Sibling of the already-fixed /actual_qty sites in stock_ledger.py and incorrect_serial_no_valuation.py.
2026-06-29 22:25:19 +05:30
Mihir Kandoi
c26ad9fc36 Merge pull request #56625 from mihir-kandoi/pg-isi-txn-fix
fix: survive a failed invoice during Import Supplier Invoice on Postgres
2026-06-29 22:20:06 +05:30
Mihir Kandoi
0431b20945 Merge pull request #56620 from mihir-kandoi/pg-orderby-limit1-tiebreakers
fix: deterministic tiebreakers for ORDER BY <date> DESC LIMIT 1 lookups (MariaDB↔Postgres parity)
2026-06-29 22:18:04 +05:30
Mihir Kandoi
4952ce0cac Merge pull request #56622 from mihir-kandoi/pg-savepoint-txn-abort
fix: savepoint catch-and-continue DB writes to survive Postgres txn-abort (InFailedSqlTransaction)
2026-06-29 22:11:47 +05:30
Mihir Kandoi
dc2d3c433d Merge pull request #56624 from mihir-kandoi/pg-ci-fanout-stop-guard
ci(postgres): fail setup if pg_ctl stop fails; drop redundant ALTER SYSTEM block
2026-06-29 22:10:52 +05:30
Mihir Kandoi
65539d44b8 fix(regional): survive a failed invoice during Import Supplier Invoice on Postgres
create_purchase_invoice caught its own failure and then ran frappe.db.set_value + log_error in the SAME transaction. On Postgres a failed insert/save aborts the whole transaction, so the error-marking died with InFailedSqlTransaction and the failure cascaded through prepare_data_for_import's per-file loop, killing the entire import; MariaDB recovers per-statement and continues.

Let create_purchase_invoice raise, and wrap each call in prepare_data_for_import in frappe.db.savepoint + rollback(save_point=...). On failure the savepoint rollback un-poisons the transaction, the error is logged, and the per-file status is set to Error and committed (self.db_set(commit=True), matching the existing process_file_data status commit) so an interrupted import durably reflects Error instead of staying at the already-committed Processing File Data; the loop then continues to the next file. The savepoint is taken after create_supplier/create_address so those are preserved exactly as before.

Behaviour change (MariaDB): a failed invoice's partially-created draft Purchase Invoice is now rolled back on BOTH engines instead of being left as an orphan draft on MariaDB. Deliberate and more correct - a failed import should not leave a partial invoice; release-note worthy.
2026-06-29 22:10:00 +05:30
Mihir Kandoi
9157c9a67b Merge pull request #56623 from frappe/patch-test-download-from-release
ci(patch): pull v14 baseline from GitHub release instead of frappe.io
2026-06-29 21:47:04 +05:30
Mihir Kandoi
f645e51338 ci(patch): fetch v14 baseline from public release URL without a token
Greptile flagged that `gh release download` with `github.token` could be
rejected for fork pull requests (token scoped to the fork, asset in
frappe/erpnext). The release is public and published, so the asset is
downloadable anonymously from objects.githubusercontent.com — drop the token
and curl the public URL directly. Removes the cross-repo token dependency and
keeps fork PRs working. Cloudflare is still bypassed since GitHub serves the
asset, not frappe.io.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:33:42 +05:30
Mihir Kandoi
b93a3bca16 ci(postgres): fail setup if pg_ctl stop fails before baking the datadir
The "Stop DB and stage datadir" step swallowed a failed `pg_ctl -m fast -w
stop` with `|| true`, then moved and tarred the PGDATA regardless. A stop
that times out or errors would bake a still-running, crash-inconsistent
cluster into the artifact every test shard consumes — and with
full_page_writes off, crash recovery can't repair torn pages. Drop the
`|| true` so a failed stop fails the job, mirroring the MariaDB sister's
"don't bake a dirty datadir" guard.

Also drop the redundant `ALTER SYSTEM SET fsync/synchronous_commit/
full_page_writes = off` block from install.sh. Its comment claimed the
postgres workflow "runs a service-container DB and never calls start-db.sh",
but it does call start-db.sh, which already applies those flags via `-o` on
every postgres start (setup job and each shard). The block was a no-op and
its justification was factually wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:29:22 +05:30
Mihir Kandoi
6e955bdf3f ci(patch): download v14 baseline from GitHub release instead of frappe.io
The Patch Test job intermittently failed on the "Download erpnext v14 backup"
step with HTTP 403 Forbidden: frappe.io sits behind Cloudflare, and wget's
default User-Agent gets flagged by bot protection on cache misses. This caused
random failures across PRs that only a re-run would clear.

Pull the fixed baseline from the v14-baseline GitHub release using the built-in
token instead. Release assets are served from GitHub's CDN and authenticated
from the runner, so no rate-limit roulette.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:28:08 +05:30
Mihir Kandoi
0978d0304f test(manufacturing): savepoint duplicate Routing insert in create_routing (Postgres)
create_routing inserts a Routing and, on DuplicateEntryError, re-fetches and updates. On Postgres the failed insert aborts the transaction so the get_doc/save in the except raises InFailedSqlTransaction; MariaDB recovers. Savepoint + rollback before the fallback path.
2026-06-29 20:50:05 +05:30
Mihir Kandoi
2b966b69ce fix(stock): savepoint per-voucher accounting repost submit (Postgres)
make_reposting_for_accounting_ledgers submits a new Repost Item Valuation per voucher in a loop under except Exception. On Postgres a failed submit aborts the transaction so the next iteration's DB work dies with InFailedSqlTransaction; MariaDB continues. Savepoint per iteration, roll back on failure. No-op on MariaDB.
2026-06-29 20:50:05 +05:30
Mihir Kandoi
864fe50b24 fix(subcontracting): savepoint Purchase Receipt submit in make_purchase_receipt (Postgres)
Same submit()/add_comment-in-except shape as the PO->SCO mapper: on Postgres a failed submit aborts the transaction so the follow-on Comment insert raises InFailedSqlTransaction; MariaDB continues. Savepoint + rollback before add_comment. No-op on MariaDB.
2026-06-29 20:50:05 +05:30
Mihir Kandoi
b6165844ed fix(buying): savepoint Subcontracting Order submit in make_subcontracting_order (Postgres)
target_doc.submit() is wrapped in except Exception whose handler calls add_comment (a Comment insert). On Postgres a failed submit poisons the transaction so the add_comment insert raises InFailedSqlTransaction; MariaDB logs the comment. Savepoint + rollback before add_comment. No-op on MariaDB.
2026-06-29 20:50:05 +05:30
Mihir Kandoi
70142d147e fix(selling): break last-sales-amount ties deterministically in Inactive Customers
get_last_sales_amt ordered by the sales date DESC only; same-date documents made the reported Last Order Amount engine-dependent. Add name DESC tiebreaker.
2026-06-29 16:29:46 +05:30
Mihir Kandoi
e52b9825e3 fix(accounts): break last-purchase-rate ties deterministically in Gross Profit
get_last_purchase_rate ordered by posting_date DESC only; same-date Purchase Invoices yielded an undefined last_purchase_rate that diverged between MariaDB and Postgres. Add purchase_invoice.name DESC tiebreaker.
2026-06-29 16:29:45 +05:30
Mihir Kandoi
93c186fea7 fix(assets): break latest asset-movement ties deterministically
get_latest_location_and_custodian ordered by transaction_date DESC only; equal-dated movements left the current location/custodian engine-dependent. Add asm.name DESC tiebreaker so both engines pick the same movement.
2026-06-29 16:29:44 +05:30
Mihir Kandoi
63ea907881 fix(accounts): break exchange-rate revaluation GLE ties deterministically
calculate_exchange_rate_using_last_gle ordered the latest-GLE lookups by posting_date DESC only. With multiple GL Entries on the latest posting_date the picked row was undefined, so MariaDB and Postgres could choose different vouchers and return a different last_exchange_rate (and revaluation gain/loss). Add gl.name DESC as a tiebreaker so both engines pick the same row; MariaDB row count unchanged.
2026-06-29 16:29:43 +05:30
ervishnucs
dead28e50e test: assert quotation from customer uses actual exchange rate 2026-06-28 20:49:56 +05:30
ervishnucs
8446be6518 fix: set currency and price list before computing quotation totals 2026-06-28 20:13:53 +05:30
ervishnucs
e61d299e63 fix: recalculate totals after setting quotation conversion rate 2026-06-28 09:53:38 +05:30
Mohd Haris
a7c1ebacbe 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>
2026-06-26 17:34:49 +05:30
Nabin Hait
87af67febe test: cover last purchase rate and valuation rate in Item Prices report 2026-06-26 15:00:39 +05:30
Nabin Hait
f2adb64f3b test: cover period, based_on and group_by filters in Delivery Note Trends 2026-06-26 14:50:54 +05:30
Nabin Hait
d2abb569d4 test: cover period, based_on and group_by filters in Purchase Receipt Trends 2026-06-26 14:49:27 +05:30
Nabin Hait
ba88667d99 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:36:42 +05:30
Nabin Hait
03ecd2fd3a test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:36:39 +05:30
Nabin Hait
a90db9a223 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:36:35 +05:30
Nabin Hait
18c4a20ad4 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:36:30 +05:30
ervishnucs
31ee3f1923 fix: set conversion_rate on quotation created from customer 2026-06-26 13:33:30 +05:30
Nabin Hait
d46b3f3627 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:28:28 +05:30
Nabin Hait
51bd2727a0 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:28:23 +05:30
Nabin Hait
5a62746dd3 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:28:19 +05:30
Nabin Hait
9cad192ccb test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:28:15 +05:30
Nabin Hait
5d217295e5 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:28:09 +05:30
Nabin Hait
e005d7021b test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:28:00 +05:30
Nabin Hait
db76533c16 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:52 +05:30
Nabin Hait
04fd425fb6 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:43 +05:30
Nabin Hait
3398e05190 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:39 +05:30
Nabin Hait
286ac77a05 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:35 +05:30
Nabin Hait
3b23e039e4 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:31 +05:30
Nabin Hait
9aef148a44 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:27 +05:30
Nabin Hait
0b35d394c5 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:22 +05:30
Nabin Hait
79bd6a9b7d test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:09 +05:30
Nabin Hait
7da4bc46bf test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:26:54 +05:30
Nabin Hait
851dfb16be test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:26:48 +05:30
Nabin Hait
abb7fec598 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:26:41 +05:30
Nabin Hait
04617b40b4 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:26:37 +05:30
Nabin Hait
78de0c976a test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:26:32 +05:30
Nabin Hait
364250467f test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:26:19 +05:30
Nabin Hait
403788324a test: add coverage for Stock and Account Value Comparison report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:40:24 +05:30
Nabin Hait
6e23e49f23 test: add coverage for Landed Cost Report report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:40:16 +05:30
Nabin Hait
6595a32d90 test: add coverage for COGS By Item Group report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:40:07 +05:30
Nabin Hait
2092909f21 test: add coverage for Serial No and Batch Traceability report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:39:51 +05:30
Nabin Hait
25bcd12e92 test: add coverage for Delivery Note Trends report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:32:51 +05:30
Nabin Hait
68330843d8 test: add coverage for Purchase Receipt Trends report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:32:43 +05:30
Nabin Hait
993578dc2f test: add coverage for Itemwise Recommended Reorder Level report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:25:48 +05:30
Nabin Hait
6d97a5d543 test: add coverage for Item Prices report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:25:33 +05:30
Nabin Hait
495677ceb7 test: add coverage for Warehouse Wise Item Balance Age and Value report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:25:26 +05:30
Nabin Hait
b5405a02cc test: add coverage for Stock Qty vs Serial No Count report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:20:22 +05:30
Nabin Hait
655dea37dd test: add coverage for Stock Qty vs Batch Qty report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:20:15 +05:30
Nabin Hait
e9d4e2cedd test: add coverage for Stock Ledger Variance report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:20:07 +05:30
Nabin Hait
ddb07bcc0a test: add coverage for Negative Batch Report report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:19:52 +05:30
Nabin Hait
06592a49c8 test: add coverage for Incorrect Serial No Valuation report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:19:45 +05:30
Nabin Hait
047014f2b5 test: add coverage for Incorrect Serial and Batch Bundle report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:19:38 +05:30
Nabin Hait
7c8ef4cfc6 test: add coverage for Incorrect Balance Qty After Transaction report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:19:31 +05:30
Nabin Hait
ec739b213d test: add coverage for FIFO Queue vs Qty After Transaction Comparison report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:19:24 +05:30
Nabin Hait
119e0caafb test: add coverage for Item-wise Consumption report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:19:17 +05:30
Nabin Hait
752aefbdfd test: add coverage for Product Bundle Balance report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:19:11 +05:30
Nabin Hait
3c749ec785 test: add coverage for Total Stock Summary report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:18:57 +05:30
Nabin Hait
c7d6b6c0c4 test: add coverage for Warehouse Wise Stock Balance report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:18:48 +05:30
Nabin Hait
7688a7653e test: add coverage for Gross and Net Profit report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:52:14 +05:30
Nabin Hait
55c6d16d69 test: add coverage for Profitability Analysis report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:49:40 +05:30
Nabin Hait
d4ec544b25 test: add value-level coverage for Budget Variance report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:38:45 +05:30
Nabin Hait
e2dc38433e test: cover Enable Serial/Batch Bundle filter in Stock Ledger report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:34:39 +05:30
Nabin Hait
8b28aa8992 test: add coverage for Stock Ledger report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:23:28 +05:30
Nabin Hait
34fbcc9514 test: add coverage for Item-wise Purchase History report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:20:33 +05:30
Nabin Hait
16c71fa102 test: add coverage for Item-wise Sales History report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:16:41 +05:30
Sudharsanan11
4fa8a12bcb fix(selling): update sales order per billed on credit note submission 2026-06-25 13:17:47 +05:30
Sudharsanan11
2373db06ec fix: support quality inspection for stock entry by purpose
Fetch QI items by warehouse direction per inspection_type, require QI on
the correct rows per stock entry purpose (finished good for Manufacture,
inward goods for Receipt/Repack, outgoing rows for issue/transfer), and
show the QI field only on those rows.
2026-06-25 12:22:28 +05:30
sokumon
90aba582ec chore: export workspaces with new schema 2026-06-12 13:08:48 +05:30
268 changed files with 19354 additions and 3000 deletions

View File

@@ -45,7 +45,9 @@ Flag a changed query that uses any of these:
- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in
`HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the
underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`.
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select.
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select
**only if it is single-valued per distinct row**; otherwise it grows the `DISTINCT` key and the
MariaDB row count (see §3) — drop the SQL `ORDER BY` and sort in Python instead.
- **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an
unquoted (or double-quoted) alias.
- **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use
@@ -119,7 +121,7 @@ These don't error, so a one-engine CI stays green. Flag them:
---
## 3. The `GROUP BY` row-count trap (the single most important rule)
## 3. The row-count trap — `GROUP BY` **and** `DISTINCT` (the single most important rule)
When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent
column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and
@@ -140,6 +142,14 @@ versa) to make a number "more correct" — that changes the MariaDB value. The w
MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of
scope for a portability fix.
**The same trap applies to `SELECT DISTINCT`.** To satisfy PostgreSQL's "an `ORDER BY` expr must
appear in the select list under `DISTINCT`" rule, **do not blindly add the ordered column to the
select** — if it is not single-valued per existing distinct row, the `DISTINCT` key grows and
MariaDB returns **more rows** (a regression), exactly as adding a non-FD column to `GROUP BY` does.
Add it only when it is functionally dependent on the existing select columns; otherwise drop the
SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is
unchanged.
---
## 4. False positives — do NOT flag these
@@ -167,17 +177,44 @@ These are auto-handled by the framework and are **not** breaks:
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
- **Recover the txn with a *scoped* savepoint, not a full `frappe.db.rollback()`, if any prior work
must survive.** A full rollback un-poisons the txn but also discards every row the handler committed
*before* the failure — which MariaDB kept (it has no statement-abort), so it's a **silent MariaDB
regression**. **"The background job / whitelist entrypoint owns the txn" does NOT make a full rollback
safe** if it did multiple inserts in a loop first — it drops the partial results MariaDB retained. A
full rollback is safe only when it (a) immediately re-`throw`s/`raise`s (MariaDB rolls back anyway),
(b) has nothing successful before it (a single op), or (c) the batch is genuinely meant to be
**atomic** (a partial result is an invalid state → rollback + mark *Failed* is correct). Otherwise use
a **per-iteration / per-record savepoint** — and keep the function's success/`None` return contract:
do **not** return the doc when the savepoint was rolled back.
---
## 6. Refactors and raw-SQL→ORM conversions are not automatically 1:1
A commit labeled a **refactor** or a **raw-`frappe.db.sql` → `frappe.qb`/ORM conversion** is meant
to preserve behaviour — but it easily doesn't, and the change passes the static checker and a
one-engine green run. **Diff the `WHERE`/predicate, the `JOIN`/`ON` conditions, and the resulting
row set — not just the `SELECT` shape.** A conversion that silently widens or narrows the filter
changes the rows touched on **both** engines and is a regression hiding under a "refactor" label.
Real example: an `UPDATE` whose bound was `posting_datetime > X` gained an
`OR (posting_datetime == X AND creation > args.creation)` branch during a "`sql` → `qb` refactor",
widening the rows updated on both engines. Even when such a change is a deliberate bug-fix it must
be called out and tested — it is **not** the no-op the refactor label implies. Confirm the
converted query touches exactly the same rows with the same values MariaDB produced before.
---
## How to review
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or
(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL),
(b) match a divergence in §2/§3 (different result across engines), or (c) change the row set under
a refactor/conversion label (§6)? If so, comment with the
portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives.
Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY:
splits the row count") so the fix is unambiguous.
The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical*
§1 breaks; the **semantic** §2/§3 divergences are exactly what a reviewer (and this guide) must
cover, because no static check can see them.
§1 breaks; the **semantic** §2/§3 divergences and the §6 refactor/conversion row-set changes are
exactly what a reviewer (and this guide) must cover, because no static check can see them.

View File

@@ -297,14 +297,10 @@ 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()";
# Durability-off for speed (no fsync/synchronous_commit/full_page_writes) is applied by
# start-db.sh's postgres `-o` flags on every start — setup job AND each test shard — so it is
# NOT repeated here. The postgres workflow runs in-runner via start-db.sh, not a service
# container.
fi
cd ~/frappe-bench || exit

View File

@@ -66,7 +66,7 @@ jobs:
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
# ~100MB from frappe.io every run.
# it from the GitHub release every run.
- name: Cache erpnext v14 backup
id: cache-v14
uses: actions/cache@v4
@@ -76,7 +76,10 @@ jobs:
- name: Download erpnext v14 backup
if: steps.cache-v14.outputs.cache-hit != 'true'
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
run: |
curl -fSL --retry 5 --retry-all-errors --retry-delay 5 \
-o ~/erpnext-v14.sql.gz \
https://github.com/frappe/erpnext/releases/download/v14-baseline/erpnext-v14.sql.gz
- name: Cache pip
uses: actions/cache@v4

View File

@@ -107,6 +107,13 @@ jobs:
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: |

View File

@@ -105,10 +105,19 @@ jobs:
FRAPPE_BRANCH: develop
BENCH_CACHE_DIR: /home/runner/bench-cache
- name: Warm up test data
run: |
cd ~/frappe-bench/
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
- name: Stop DB and stage datadir
run: |
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
# Clean shutdown so the baked datadir is consistent. Do NOT swallow a failed stop with
# `|| true`: moving and tarring a still-running cluster ships a torn datadir the shards
# cannot crash-recover (full_page_writes is off). Fail the job instead — mirrors the
# MariaDB sister's "don't bake a dirty datadir" guard.
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
- name: Package bench for test shards
@@ -128,7 +137,7 @@ jobs:
compression-level: 0
test:
name: Python Unit Tests (PG)
name: Python Unit Tests
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60

File diff suppressed because one or more lines are too long

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

@@ -179,7 +179,12 @@ def normalize_ctx_input(T: type) -> callable:
def decorator(func: callable):
# conserve annotations for frappe.utils.typing_validations
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
@functools.wraps(
func,
assigned=(
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
),
)
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
if isinstance(ctx, Document):
ctx = T(**ctx.as_dict())

View File

@@ -582,6 +582,7 @@ def make_gl_entries(
frappe.db.commit()
except Exception as e:
if frappe.in_test:
frappe.db.rollback()
doc.log_error(f"Error while processing deferred accounting for Invoice {doc.name}")
raise e
else:

View File

@@ -37,6 +37,10 @@
"account_type": "Stock",
"account_category": "Stock Assets"
},
"Stock Delivered But Not Billed": {
"account_type": "Stock Delivered But Not Billed",
"account_category": "Stock Assets"
},
"account_type": "Stock",
"account_category": "Stock Assets"
},
@@ -223,10 +227,6 @@
"Stock Received But Not Billed": {
"account_type": "Stock Received But Not Billed",
"account_category": "Trade Payables"
},
"Stock Delivered But Not Billed": {
"account_type": "Stock Delivered But Not Billed",
"account_category": "Trade Payables"
}
},
"Duties and Taxes": {

View File

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

@@ -47,6 +47,7 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
for key, value in header_map.items():
fields.update({key: d[int(value) - 1]})
frappe.db.savepoint("bank_entry")
try:
bank_transaction = frappe.get_doc({"doctype": "Bank Transaction"})
bank_transaction.update(fields)
@@ -56,7 +57,8 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
bank_transaction.submit()
success += 1
except Exception:
bank_transaction.log_error("Bank entry creation failed")
frappe.db.rollback(save_point="bank_entry")
frappe.log_error(title="Bank entry creation failed")
errors += 1
return {"success": success, "errors": errors}

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

@@ -601,6 +601,7 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
.select(gl.voucher_type, gl.voucher_no)
.where(Criterion.all(conditions))
.orderby(gl.posting_date, order=Order.desc)
.orderby(gl.name, order=Order.desc)
.limit(1)
.run()[0]
)
@@ -615,6 +616,7 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
(gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account)
)
.orderby(gl.posting_date, order=Order.desc)
.orderby(gl.name, order=Order.desc)
.limit(1)
.run()[0][0]
)

View File

@@ -107,6 +107,9 @@ def auto_create_fiscal_year():
)
for d in fiscal_year:
# savepoint so a duplicate-year INSERT (Fiscal Year autoname=field:year) that aborts the
# statement doesn't poison the whole scheduler transaction on Postgres and kill the next iteration
frappe.db.savepoint("auto_create_fiscal_year")
try:
current_fy = frappe.get_doc("Fiscal Year", d[0])
@@ -127,7 +130,7 @@ def auto_create_fiscal_year():
new_fy.insert(ignore_permissions=True)
except frappe.NameError:
pass
frappe.db.rollback(save_point="auto_create_fiscal_year")
def get_from_and_to_date(fiscal_year):

View File

@@ -471,6 +471,25 @@ def on_doctype_update():
frappe.db.add_index("GL Entry", ["posting_date", "company"])
frappe.db.add_index("GL Entry", ["party_type", "party"])
if frappe.db.db_type == "postgres":
# Postgres-only partial/covering indexes for the financial reports (General Ledger, Trial
# Balance, Balance Sheet, P&L), which always filter `is_cancelled = 0` and scope by company.
# `where`/`include` are no-ops on MariaDB and its optimizer ignores these anyway, so they are
# added only on postgres to avoid dead write overhead on this insert-hot table.
frappe.db.add_index(
"GL Entry",
["company", "posting_date", "account"],
index_name="gle_active_detail",
where="is_cancelled = 0",
)
frappe.db.add_index(
"GL Entry",
["company", "account", "posting_date"],
index_name="gle_active_cover",
where="is_cancelled = 0",
include=["debit", "credit"],
)
def rename_gle_sle_docs():
for doctype in ["GL Entry", "Stock Ledger Entry"]:

View File

@@ -29,7 +29,7 @@ frappe.ui.form.on("Journal Entry", {
refresh(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();
@@ -232,6 +232,13 @@ Object.assign(erpnext.journal_entry, {
}
},
lock_reversal_entry(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);
},
add_custom_buttons(frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(

View File

@@ -65,6 +65,7 @@ def start_merge(docname):
total = len(ledger_merge.merge_accounts)
for row in ledger_merge.merge_accounts:
if not row.merged:
frappe.db.savepoint("ledger_merge_row")
try:
merge_account(
row.account,
@@ -79,8 +80,7 @@ def start_merge(docname):
{"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total},
)
except Exception:
if not frappe.in_test:
frappe.db.rollback()
frappe.db.rollback(save_point="ledger_merge_row")
ledger_merge.log_error("Ledger merge failed")
finally:
if successful_merges == total:

View File

@@ -514,10 +514,12 @@ class PaymentEntry(AccountsController):
invoice_names.add((ref.reference_doctype, ref.reference_name))
for doctype, name in invoice_names:
frappe.db.savepoint("subscription_update")
try:
doc = frappe.get_doc(doctype, name)
doc.refresh_subscription_status()
except Exception:
frappe.db.rollback(save_point="subscription_update")
frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name))
def set_missing_values(self):
@@ -2793,6 +2795,9 @@ def get_open_payment_requests_for_references(references=None):
.where(PR.docstatus == 1)
.where(PR.outstanding_amount > 0) # to avoid old PRs with 0 outstanding amount
.orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
# unique tiebreaker so PRs sharing a transaction_date allocate in the same order on both engines
.orderby(PR.creation, order=frappe.qb.asc)
.orderby(PR.name, order=frappe.qb.asc)
).run(as_dict=True)
if not response:

View File

@@ -360,12 +360,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
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.get_all(
"GL Entry",

View File

@@ -663,7 +663,6 @@ class POSInvoice(SalesInvoice):
def set_pos_fields(self, for_validate=False):
"""Set retail related fields from POS Profiles"""
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_pos_profile,
get_pos_profile_item_details_,
)
@@ -736,7 +735,7 @@ class POSInvoice(SalesInvoice):
for item in self.get("items"):
if item.get("item_code"):
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), profile.get("company"), profile
frappe._dict(item.as_dict()), profile.get("company"), profile
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):

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

@@ -33,9 +33,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
self.make_item_gl_entries(gl_entries)
disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
disable_sdbnb_in_sr, is_sdbnb_enabled = frappe.get_cached_value(
"Company", doc.company, ["disable_sdbnb_in_sr", "enable_stock_delivered_but_not_billed"]
)
if not (doc.is_return and disable_sdbnb_in_sr):
if is_sdbnb_enabled and not (doc.is_return and disable_sdbnb_in_sr):
self.stock_delivered_but_not_billed_gl_entries(gl_entries)
self.make_precision_loss_gl_entry(gl_entries)

View File

@@ -126,13 +126,13 @@ class POSService:
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
from erpnext.stock.get_item_details import get_pos_profile_item_details_
for item in self.doc.get("items"):
if not item.get("item_code"):
continue
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
frappe._dict(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):

View File

@@ -1576,14 +1576,14 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
company = "_Test Company with perpetual inventory"
company = "_Test SDBNB Company"
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
make_purchase_receipt(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
warehouse="Stores - _TSDBNB",
cost_center="Main - _TSDBNB",
qty=5,
rate=100,
)
@@ -1591,13 +1591,13 @@ class TestSalesInvoice(ERPNextTestSuite):
dn = create_delivery_note(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
warehouse="Stores - _TSDBNB",
cost_center="Main - _TSDBNB",
qty=2,
rate=300,
)
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - _TSDBNB")
si = make_sales_invoice(dn.name)
si.insert()
@@ -1609,9 +1609,9 @@ class TestSalesInvoice(ERPNextTestSuite):
fields=["account", "debit", "credit"],
)
sdbnb_credit = sum(
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - _TSDBNB"
)
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - _TSDBNB")
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
self.assertTrue(sdbnb_credit > 0)

View File

@@ -640,13 +640,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:
@@ -715,7 +717,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

@@ -174,7 +174,17 @@ frappe.query_reports["Accounts Payable"] = {
},
get_datatable_options(options) {
return Object.assign(options, { checkboxColumn: true });
return Object.assign(options, {
checkboxColumn: true,
events: {
onCheckRow: () => erpnext.accounts.toggle_create_pe_primary_action(frappe.query_report),
},
});
},
after_refresh: function (report) {
report.datatable?.rowmanager?.checkAll(false);
report.page.clear_primary_action();
},
onload: function (report) {
@@ -186,20 +196,27 @@ frappe.query_reports["Accounts Payable"] = {
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
if (frappe.model.can_create("Payment Entry")) {
report.page.add_inner_button(
__("Create Payment Entries"),
function () {
erpnext.accounts.create_payment_entries_from_payable_report(report);
},
__("Actions")
);
}
},
};
frappe.provide("erpnext.accounts");
erpnext.accounts.toggle_create_pe_primary_action = function (report) {
if (!report || !report.datatable || !frappe.model.can_create("Payment Entry")) return;
const has_purchase_invoice = report.datatable.rowmanager
.getCheckedRows()
.some((i) => report.datatable.datamanager.data[i]?.voucher_type === "Purchase Invoice");
if (has_purchase_invoice) {
report.page.set_primary_action(__("Create Payment Entries"), () =>
erpnext.accounts.create_payment_entries_from_payable_report(report)
);
} else {
report.page.clear_primary_action();
}
};
erpnext.accounts.create_payment_entries_from_payable_report = function (report) {
const datatable = report.datatable;
if (!datatable) return;

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 3,
"is_standard": "Yes",
"modified": "2026-06-25 12:03:36.559152",
"modified": "2026-07-01 13:37:41.185347",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -40,6 +40,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -0,0 +1,144 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.accounts_payable_summary.accounts_payable_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsPayableSummary(ERPNextTestSuite):
"""Payable Summary is a thin wrapper over AccountsReceivableSummary with
account_type=Payable; these tests lock the supplier-side output: invoiced,
advance, paid, outstanding, ageing buckets and the optional GL-balance /
future-payment columns."""
def setUp(self):
frappe.set_user("Administrator")
self.maxDiff = None
self.company = "_Test Company"
self.supplier = "_Test Supplier"
def _filters(self, **overrides):
filters = {
"company": self.company,
"supplier": self.supplier,
"posting_date": today(),
"range": "30, 60, 90, 120",
}
filters.update(overrides)
return filters
def _make_invoice(self, rate=200):
return make_purchase_invoice(
company=self.company,
supplier=self.supplier,
qty=1,
rate=rate,
price_list_rate=rate,
posting_date=today(),
)
def _expected_row(self, pi, **overrides):
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
row = {
"party_type": "Supplier",
"advance": 0,
"party": self.supplier,
"invoiced": 200.0,
"paid": 0.0,
"credit_note": 0.0,
"outstanding": 200.0,
"range1": 200.0,
"range2": 0.0,
"range3": 0.0,
"range4": 0.0,
"range5": 0.0,
"total_due": 200.0,
"future_amount": 0.0,
"sales_person": [],
"currency": pi.currency,
"supplier_group": supplier_group,
}
row.update(overrides)
return row
def test_01_payable_summary_output(self):
"""Invoiced -> advance -> partial payment progression for a single supplier."""
filters = self._filters()
pi = self._make_invoice()
expected = self._expected_row(pi)
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# advance payment: pay 50 but allocate nothing against the invoice
pe = get_payment_entry(pi.doctype, pi.name)
pe.paid_amount = 50
pe.references[0].allocated_amount = 0
pe.save().submit()
expected.update({"advance": 50.0, "outstanding": 150.0, "range1": 150.0, "total_due": 150.0})
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# partial payment allocated against the invoice
pe = get_payment_entry(pi.doctype, pi.name)
pe.paid_amount = 125
pe.references[0].allocated_amount = 125
pe.save().submit()
expected.update(
{"advance": 50.0, "paid": 125.0, "outstanding": 25.0, "range1": 25.0, "total_due": 25.0}
)
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
@ERPNextTestSuite.change_settings("Buying Settings", {"supp_master_name": "Naming Series"})
def test_02_gl_balance_and_future_payment_columns(self):
"""Naming-series naming adds party_name; show_gl_balance / show_future_payments
add their columns; a fully-paid invoice drops out of the report."""
filters = self._filters()
pi = self._make_invoice()
pe = get_payment_entry(pi.doctype, pi.name)
pe.paid_amount = 150
pe.references[0].allocated_amount = 150
pe.save().submit()
expected = self._expected_row(
pi,
party_name=frappe.db.get_value("Supplier", self.supplier, "supplier_name"),
paid=150.0,
outstanding=50.0,
range1=50.0,
total_due=50.0,
)
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# GL balance reconciliation columns
filters.update({"show_gl_balance": True})
expected.update({"gl_balance": 50.0, "diff": 0.0})
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# future payment columns
filters.update({"show_future_payments": True})
expected.update({"remaining_balance": 50.0})
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# clear the remaining balance -> supplier drops out of the summary entirely
get_payment_entry(pi.doctype, pi.name).save().submit()
rows = execute(filters)[1]
self.assertEqual(len(rows), 0)

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 5,
"is_standard": "Yes",
"modified": "2026-06-25 12:03:28.812092",
"modified": "2026-07-01 13:37:44.167999",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -34,6 +34,6 @@
"role": "Accounts User"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -277,7 +277,7 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
return chart
def execute_synced_report(filters):
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):

View File

@@ -0,0 +1,64 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.bank_clearance_summary.bank_clearance_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
BANK_ACCOUNT = "_Test Bank - _TC"
class TestBankClearanceSummary(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"account": BANK_ACCOUNT,
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
}
)
filters.update(extra)
return execute(filters)[1]
def find_row(self, data, payment_entry):
for row in data:
if row[1] == payment_entry:
return row
return None
def test_uncleared_then_cleared_journal_entry(self):
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 5000, submit=True, posting_date="2026-06-01")
# Uncleared: the bank row appears with the debit amount and no clearance date
row = self.find_row(self.run_report(), je.name)
self.assertIsNotNone(row, "Journal Entry not listed in Bank Clearance Summary")
self.assertEqual(row[0], "Journal Entry")
self.assertEqual(frappe.utils.getdate(row[2]), frappe.utils.getdate("2026-06-01"))
self.assertIsNone(row[4]) # clearance_date empty -> uncleared
self.assertEqual(row[5], "Sales - _TC") # against account
self.assertEqual(row[6], 5000) # debit - credit on the bank account
# Cleared: set the clearance date on the Journal Entry and re-run
frappe.db.set_value("Journal Entry", je.name, "clearance_date", "2026-06-05")
row = self.find_row(self.run_report(), je.name)
self.assertIsNotNone(row)
self.assertEqual(frappe.utils.getdate(row[4]), frappe.utils.getdate("2026-06-05"))
self.assertEqual(row[6], 5000)
def test_date_filter_excludes_out_of_range_entries(self):
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 3000, submit=True, posting_date="2026-06-10")
# Within range: present
self.assertIsNotNone(self.find_row(self.run_report(), je.name))
# Window entirely after the posting date (from_date lower bound): excluded
after = self.run_report(from_date="2026-07-01", to_date="2026-12-31")
self.assertIsNone(self.find_row(after, je.name))
# Window ending before the posting date (to_date upper bound): excluded
before = self.run_report(from_date="2026-01-01", to_date="2026-06-09")
self.assertIsNone(self.find_row(before, je.name))

View File

@@ -31,7 +31,7 @@ def get_report_filters(report_filters):
]
if report_filters.get("purchase_invoice"):
filters.append(["Purchase Invoice", "per_received", "in", [report_filters.get("purchase_invoice")]])
filters.append(["Purchase Invoice", "name", "=", report_filters.get("purchase_invoice")])
return filters

View File

@@ -0,0 +1,102 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.billed_items_to_be_received.billed_items_to_be_received import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestBilledItemsToBeReceived(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"posting_date": today(),
}
)
filters.update(extra)
return execute(filters)[1]
def get_rows_for(self, data, pi_name):
return [row for row in data if row.get("name") == pi_name]
def test_billed_but_not_received_item_appears(self):
pi = make_purchase_invoice(
supplier="_Test Supplier",
item_code="_Test Item",
qty=5,
rate=200,
update_stock=0,
)
rows = self.get_rows_for(self.run_report(), pi.name)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row.get("supplier"), "_Test Supplier")
self.assertEqual(row.get("company"), "_Test Company")
self.assertEqual(row.get("item_code"), "_Test Item")
self.assertEqual(row.get("qty"), 5)
self.assertEqual(row.get("received_qty"), 0)
self.assertEqual(row.get("rate"), 200)
self.assertEqual(row.get("amount"), 1000)
def test_stock_updating_invoice_is_excluded(self):
"""update_stock=1 means the item is already received; it must not appear."""
pi = make_purchase_invoice(
supplier="_Test Supplier",
item_code="_Test Item",
qty=5,
rate=200,
update_stock=1,
)
rows = self.get_rows_for(self.run_report(), pi.name)
self.assertEqual(len(rows), 0)
def test_fully_received_invoice_drops_off(self):
"""When per_received reaches 100 the invoice is fully received and drops off."""
pi = make_purchase_invoice(
supplier="_Test Supplier",
item_code="_Test Item",
qty=5,
rate=200,
update_stock=0,
)
# Present while nothing has been received.
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 1)
frappe.db.set_value("Purchase Invoice", pi.name, "per_received", 100)
# Absent once fully received.
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 0)
def test_posting_date_upper_bound_filter(self):
"""A PI posted after the filter's posting_date must be excluded."""
pi = make_purchase_invoice(
supplier="_Test Supplier",
item_code="_Test Item",
qty=5,
rate=200,
update_stock=0,
)
rows = self.get_rows_for(self.run_report(posting_date="2000-01-01"), pi.name)
self.assertEqual(len(rows), 0)
def test_purchase_invoice_filter_scopes_to_that_invoice(self):
"""The optional purchase_invoice filter must narrow to that invoice only."""
pi = make_purchase_invoice(
supplier="_Test Supplier", item_code="_Test Item", qty=5, rate=200, update_stock=0
)
other = make_purchase_invoice(
supplier="_Test Supplier", item_code="_Test Item", qty=3, rate=200, update_stock=0
)
names = {row.get("name") for row in self.run_report(purchase_invoice=pi.name)}
self.assertEqual(names, {pi.name})
self.assertNotIn(other.name, names)

View File

@@ -2,26 +2,116 @@
# For license information, please see license.txt
import frappe
from frappe.utils import nowdate
from erpnext.accounts.doctype.budget.test_budget import make_budget, set_total_expense_zero
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.budget_variance_report.budget_variance_report import execute
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
ACCOUNT = "_Test Account Cost for Goods Sold - _TC"
COST_CENTER = "_Test Cost Center - _TC"
COST_CENTER_2 = "_Test Cost Center 2 - _TC"
class TestBudgetVarianceReport(ERPNextTestSuite):
def setUp(self):
self.fy = get_fiscal_year(nowdate())[0]
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_fiscal_year": self.fy,
"to_fiscal_year": self.fy,
"period": "Yearly",
"budget_against": "Cost Center",
**extra,
}
)
return execute(filters)[1]
def report_row(self, data, dimension, account=ACCOUNT):
row = next(
(r for r in data if r["budget_against"] == dimension and r["account"] == account),
None,
)
self.assertIsNotNone(row, f"No report row for {dimension} / {account}")
return row
def field(self, label):
return frappe.scrub(f"{label} {self.fy}")
def test_report_executes(self):
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
# both MariaDB and postgres.
company = frappe.db.get_value("Company", {}, "name")
fy = frappe.db.get_value("Fiscal Year", {}, "name", order_by="year_start_date desc")
columns, *_rest = execute(
frappe._dict(
{
"company": company,
"from_fiscal_year": fy,
"to_fiscal_year": fy,
"company": "_Test Company",
"from_fiscal_year": self.fy,
"to_fiscal_year": self.fy,
"period": "Yearly",
"budget_against": "Cost Center",
}
)
)
self.assertTrue(columns)
def test_budget_amount_shown_with_zero_actual(self):
# neutralise any committed actuals so the exact Actual/Variance assertions hold
set_total_expense_zero(nowdate(), "cost_center")
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
row = self.report_row(self.run_report(), COST_CENTER)
self.assertEqual(row[self.field("Budget")], 120000)
self.assertEqual(row[self.field("Actual")], 0)
self.assertEqual(row[self.field("Variance")], 120000)
def test_actual_expense_updates_actual_and_variance(self):
# zero out pre-committed actuals: keeps Actual exact and avoids the budget's
# "Stop" action rejecting the journal entry when prior actuals already exist
set_total_expense_zero(nowdate(), "cost_center")
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
# book an actual expense well within the annual budget so the "Stop" action does not block it
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
row = self.report_row(self.run_report(), COST_CENTER)
self.assertEqual(row[self.field("Actual")], 50000)
self.assertEqual(row[self.field("Variance")], 70000) # 120000 - 50000
def test_budget_against_filter_limits_dimensions(self):
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER_2, budget_amount=80000, submit_budget=1
)
data = self.run_report(budget_against_filter=[COST_CENTER])
dimensions = {row["budget_against"] for row in data}
self.assertEqual(dimensions, {COST_CENTER})
def test_monthly_period_totals(self):
# zero out pre-committed actuals so total_actual reflects only this test's entry
set_total_expense_zero(nowdate(), "cost_center")
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
row = self.report_row(self.run_report(period="Monthly"), COST_CENTER)
# totals roll up the per-month columns across the year
self.assertEqual(row["total_budget"], 120000)
self.assertEqual(row["total_actual"], 50000)
self.assertEqual(row["total_variance"], 70000)
def test_no_budget_returns_no_rows(self):
# a dimension without any budget produces no report rows
data = self.run_report(budget_against_filter=["_Test Write Off Cost Center - _TC"])
self.assertEqual(data, [])

View File

@@ -0,0 +1,82 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
import frappe
from frappe.utils.formatters import format_value
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.calculated_discount_mismatch.calculated_discount_mismatch import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCalculatedDiscountMismatch(ERPNextTestSuite):
"""Integrity detector: flag transactions whose stored ``discount_amount`` was tampered
after the fact (a Version records the change) while ``additional_discount_percentage``
stayed the same, so the stored amount no longer matches the percentage-derived value.
"""
def run_report(self, docname: str) -> dict | None:
"""Run the (filter-less) report and return the row for ``docname``, if any."""
_columns, data = execute(frappe._dict({}))
return next((row for row in data if row["docname"] == docname), None)
def create_discounted_invoice(self) -> "frappe.Document":
"""Draft Sales Invoice (rate 1000) with a 10% additional discount.
The controller derives ``discount_amount`` = 10% of the grand total = 100.00,
so the stored amount is consistent with the percentage.
"""
invoice = create_sales_invoice(rate=1000, qty=1, do_not_submit=1)
invoice.additional_discount_percentage = 10
invoice.save()
invoice.reload()
return invoice
def test_consistent_discount_is_not_flagged(self):
"""A submitted invoice whose discount_amount matches its percentage is not reported."""
invoice = self.create_discounted_invoice()
invoice.submit()
invoice.reload()
self.assertEqual(invoice.discount_amount, 100.0)
self.assertIsNone(self.run_report(invoice.name))
def test_tampered_discount_is_flagged(self):
"""Directly overwriting discount_amount (leaving the percentage intact) is reported.
This reproduces the real-world integrity breach: a Version records the
``discount_amount`` change, its ``new`` value equals the current stored amount, and
``additional_discount_percentage`` was not touched -- exactly the shape the report
queries for.
"""
invoice = self.create_discounted_invoice()
consistent_amount = invoice.discount_amount # 100.00, matches the 10% percentage
tampered_amount = 250.0
discount_field = frappe.get_meta("Sales Invoice").get_field("discount_amount")
# Format exactly as the report does so version.new == format_value(current amount).
suspected = format_value(consistent_amount, df=discount_field, currency=invoice.currency)
actual = format_value(tampered_amount, df=discount_field, currency=invoice.currency)
# Tamper the stored amount directly, bypassing the controller that would recompute it.
frappe.db.set_value("Sales Invoice", invoice.name, "discount_amount", tampered_amount)
self.record_discount_change(invoice.name, suspected, actual)
row = self.run_report(invoice.name)
self.assertIsNotNone(row)
self.assertEqual(row["doctype"], "Sales Invoice")
self.assertEqual(row["actual_discount_percentage"], 10.0)
self.assertEqual(row["actual_discount_amount"], actual)
self.assertEqual(row["suspected_discount_amount"], suspected)
def record_discount_change(self, docname: str, old: str, new: str) -> None:
"""Insert the Version audit row a direct discount_amount edit would have produced."""
version = frappe.new_doc("Version")
version.ref_doctype = "Sales Invoice"
version.docname = docname
version.data = json.dumps({"changed": [["discount_amount", old, new]]}, separators=(",", ":"))
version.flags.ignore_version = True
version.insert(ignore_permissions=True)

View File

@@ -582,7 +582,12 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com
total += flt(row[company])
row["has_value"] = has_value
row["total"] = total
# when accumulating into the group company, that company's column already consolidates its
# descendants, so summing every company column would double-count; use the group total directly.
if filters.get("accumulated_in_group_company"):
row["total"] = flt(row.get(filters.company, 0.0), 3)
else:
row["total"] = total
data.append(row)

View File

@@ -0,0 +1,129 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt, today
from erpnext.accounts.report.consolidated_financial_statement.consolidated_financial_statement import (
execute,
)
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
PARENT_COMPANY = "Parent Group Company India"
CHILD_COMPANY = "Child Company India"
class TestConsolidatedFinancialStatement(ERPNextTestSuite):
"""Consolidation is exercised via the bootstrap group of companies
(`Parent Group Company India` with child `Child Company India`). Income and
expense posted in the child company must surface in the report that is run
for the parent (group) company."""
def setUp(self):
self.fiscal_year = get_fiscal_year(today(), company=PARENT_COMPANY)[0]
def run_report(self, **extra):
filters = frappe._dict(
{
"company": PARENT_COMPANY,
"filter_based_on": "Fiscal Year",
"from_fiscal_year": self.fiscal_year,
"to_fiscal_year": self.fiscal_year,
"periodicity": "Yearly",
"include_default_book_entries": 1,
}
)
filters.update(extra)
return execute(filters)[1]
def post_journal_entry(self, debit_account, credit_account, amount):
je = frappe.new_doc("Journal Entry")
je.posting_date = today()
je.company = CHILD_COMPANY
je.set(
"accounts",
[
{"account": debit_account, "debit_in_account_currency": amount},
{"account": credit_account, "credit_in_account_currency": amount},
],
)
je.save()
je.submit()
return je
def get_row(self, data, account_name_fragment, last_match=False):
"""Return the first (or last) row whose account_name contains the fragment.
Pass ``last_match=True`` to get the leaf/most-specific match when the fragment
is also a prefix of a parent group account (parents precede children in tree order).
"""
found = None
for row in data:
if account_name_fragment in str(row.get("account_name") or ""):
if not last_match:
return row
found = row
return found
def test_profit_and_loss_reflects_child_company_income(self):
amount = 7000
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
self.assertTrue(data, "Report returned no rows")
# child's Sales account is mapped onto the parent chart (Sales - PGCI)
sales_row = self.get_row(data, "Sales", last_match=True)
self.assertIsNotNone(sales_row, "Sales row missing from consolidated P&L")
# >= so a pre-existing Sales balance in the fiscal year doesn't make this brittle
self.assertGreaterEqual(flt(sales_row.get(CHILD_COMPANY)), amount)
total_income_row = self.get_row(data, "Total Income (Credit)")
self.assertIsNotNone(total_income_row, "Total Income row missing")
self.assertGreaterEqual(flt(total_income_row.get("total")), amount)
def test_profit_and_loss_reflects_child_company_expense(self):
amount = 3000
self.post_journal_entry("Marketing Expenses - CCI", "Cash - CCI", amount)
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
expense_row = self.get_row(data, "Marketing Expenses", last_match=True)
self.assertIsNotNone(expense_row, "Marketing Expenses row missing from consolidated P&L")
self.assertGreaterEqual(flt(expense_row.get(CHILD_COMPANY)), amount)
total_expense_row = self.get_row(data, "Total Expense (Debit)")
self.assertIsNotNone(total_expense_row, "Total Expense row missing")
self.assertGreaterEqual(flt(total_expense_row.get("total")), amount)
def test_accumulated_in_group_company_rolls_up_to_parent(self):
"""With `accumulated_in_group_company`, the child's amount is also
accumulated into the parent company column."""
amount = 5000
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=1)
sales_row = self.get_row(data, "Sales", last_match=True)
self.assertIsNotNone(sales_row)
child_value = flt(sales_row.get(CHILD_COMPANY))
self.assertGreaterEqual(child_value, amount)
# parent column picks up the child value when accumulated
self.assertEqual(flt(sales_row.get(PARENT_COMPANY)), child_value)
# the total equals the consolidated (group) value, not the sum of parent + child
# columns -- this is the regression guard for the double-count fix
self.assertEqual(flt(sales_row.get("total")), child_value)
def test_balance_sheet_executes_and_returns_rows(self):
# posting income leaves a balancing entry in the child's Cash (Asset) account
amount = 4000
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
data = self.run_report(report="Balance Sheet", accumulated_in_group_company=0)
self.assertTrue(data, "Balance Sheet returned no rows")
cash_row = self.get_row(data, "Cash")
self.assertIsNotNone(cash_row, "Cash asset row missing from consolidated Balance Sheet")
self.assertGreaterEqual(flt(cash_row.get(CHILD_COMPANY)), amount)

View File

@@ -0,0 +1,94 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.custom_financial_statement.custom_financial_statement import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCustomFinancialStatement(ERPNextTestSuite):
"""The report renders a Financial Report Template through FinancialReportEngine.
These tests exercise its own entry point: a template with an account-data row
and a calculated row, and the guard that returns nothing without a template."""
def setUp(self):
frappe.set_user("Administrator")
self.company = "_Test Company"
self.expense_account = "_Test Account Cost for Goods Sold - _TC"
self.cash_account = "Cash - _TC"
def _make_template(self):
# rows filter by exact account name so the value is isolated from other data
template_name = f"Test Custom FS {frappe.generate_hash()[:8]}"
return frappe.get_doc(
{
"doctype": "Financial Report Template",
"template_name": template_name,
"report_type": "Profit and Loss Statement",
"rows": [
{
"reference_code": "EXP",
"display_name": "Test Expense",
"indentation_level": 0,
"data_source": "Account Data",
"balance_type": "Closing Balance",
"calculation_formula": f'["name", "=", "{self.expense_account}"]',
},
{
"reference_code": "EXP_X2",
"display_name": "Expense Doubled",
"indentation_level": 0,
"data_source": "Calculated Amount",
"calculation_formula": "EXP * 2",
},
],
}
).insert()
def _filters(self, template_name):
return frappe._dict(
{
"company": self.company,
"report_template": template_name,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-01-01",
"period_end_date": "2024-12-31",
"filter_based_on": "Date Range",
"periodicity": "Yearly",
"accumulated_values": 0,
}
)
def test_account_and_calculated_rows(self):
make_journal_entry(
self.expense_account,
self.cash_account,
2000,
posting_date="2024-06-15",
company=self.company,
submit=True,
)
template = self._make_template()
columns, data = execute(self._filters(template.template_name))[:2]
self.assertTrue(columns)
rows = {row.get("account_name"): row for row in data}
self.assertIn("Test Expense", rows)
self.assertIn("Expense Doubled", rows)
period_keys = rows["Test Expense"].get("_segment_info", {}).get("period_keys", [])
self.assertTrue(period_keys, "expected at least one period key in _segment_info")
period_key = period_keys[0]
# the account-data row picks up the posted expense; the calculated row doubles it
self.assertEqual(flt(rows["Test Expense"][period_key]), 2000.0)
self.assertEqual(flt(rows["Expense Doubled"][period_key]), 4000.0)
def test_no_template_returns_nothing(self):
"""Without a report_template the report short-circuits and returns None."""
self.assertIsNone(execute(frappe._dict({"company": self.company})))

View File

@@ -0,0 +1,89 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.delivered_items_to_be_billed.delivered_items_to_be_billed import execute
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestDeliveredItemsToBeBilled(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"posting_date": "2026-06-30",
}
)
filters.update(extra)
return execute(filters)[1]
def stock_up_item(self):
make_stock_entry(
item_code="_Test Item",
target="Stores - _TC",
qty=20,
basic_rate=100,
posting_date="2026-05-25",
)
def test_unbilled_delivery_note_appears(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-06-01",
)
rows = self.run_report(delivery_note=dn.name)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row.name, dn.name)
self.assertEqual(row.customer, "_Test Customer")
self.assertEqual(row.item_code, "_Test Item")
self.assertEqual(row.amount, 1500)
self.assertEqual(row.billed_amount, 0)
self.assertEqual(row.returned_amount, 0)
self.assertEqual(row.pending_amount, 1500)
def test_fully_billed_delivery_note_drops_out(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-06-01",
)
self.assertEqual(len(self.run_report(delivery_note=dn.name)), 1)
si = make_sales_invoice(dn.name)
si.posting_date = "2026-06-02"
si.set_posting_time = 1
si.insert()
si.submit()
self.assertEqual(self.run_report(delivery_note=dn.name), [])
def test_date_filter_excludes_later_delivery_notes(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-07-15",
)
rows = self.run_report(delivery_note=dn.name, posting_date="2026-06-30")
self.assertEqual(rows, [])

View File

@@ -0,0 +1,82 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.dimension_wise_accounts_balance_report.dimension_wise_accounts_balance_report import (
execute,
)
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
class TestDimensionWiseAccountsBalance(ERPNextTestSuite):
"""Balances accounts one column per value of an accounting dimension (here
Cost Center). Locks the two behaviours that matter: an entry lands in its
own dimension column as debit - credit, and children roll up into parents."""
def setUp(self):
frappe.set_user("Administrator")
self.company = "_Test Company"
self.expense_account = "_Test Account Cost for Goods Sold - _TC"
self.cash_account = "Cash - _TC"
def _make_cost_center(self, name):
full_name = f"{name} - _TC"
if not frappe.db.exists("Cost Center", full_name):
frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": name,
"parent_cost_center": "_Test Company - _TC",
"company": self.company,
"is_group": 0,
}
).insert()
return full_name
def _filters(self, **overrides):
filters = frappe._dict(
{
"company": self.company,
"dimension": "Cost Center",
"fiscal_year": get_fiscal_year(today(), company=self.company)[0],
}
)
filters.update(overrides)
return filters
def test_dimension_column_and_rollup(self):
# a dedicated cost center isolates our column from any other posted data
cost_center = self._make_cost_center("Test Dimension CC")
make_journal_entry(
self.expense_account,
self.cash_account,
300,
cost_center=cost_center,
posting_date=today(),
submit=True,
)
columns, data = execute(self._filters())
column = frappe.scrub(cost_center)
self.assertIn(column, [c["fieldname"] for c in columns])
rows = {row["account"]: row for row in data}
# the entry shows as debit - credit under its own dimension column
self.assertEqual(rows[self.expense_account][column], 300.0)
self.assertEqual(rows[self.cash_account][column], -300.0)
# and rolls up into each account's parent (isolated to our cost center)
expense_parent = frappe.db.get_value("Account", self.expense_account, "parent_account")
cash_parent = frappe.db.get_value("Account", self.cash_account, "parent_account")
self.assertEqual(rows[expense_parent][column], 300.0)
self.assertEqual(rows[cash_parent][column], -300.0)
def test_requires_fiscal_year(self):
filters = self._filters()
filters.pop("fiscal_year")
self.assertRaises(frappe.ValidationError, execute, filters)

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
"modified": "2026-06-22 13:38:35.057216",
"modified": "2026-07-01 13:36:06.682661",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -820,7 +820,7 @@ def get_columns(filters):
return columns
def execute_synced_report(filters):
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):

View File

@@ -0,0 +1,85 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.gross_and_net_profit_report.gross_and_net_profit_report import execute
from erpnext.tests.utils import ERPNextTestSuite
BANK = "_Test Bank - _TC"
INCOME_PARENT = "Income - _TC"
EXPENSE_PARENT = "Expenses - _TC"
# bootstrap leaf accounts that already have include_in_gross = 0 (no creation needed)
NON_GROSS_INCOME = "_Test Account Sales - _TC"
NON_GROSS_EXPENSE = "_Test Account Cost for Goods Sold - _TC"
# an isolated fiscal year so other accounts contribute nothing to the totals
FY = "_Test Fiscal Year 2049"
DATE = "2049-06-01"
class TestGrossAndNetProfitReport(ERPNextTestSuite):
def run_report(self, from_fiscal_year=FY, to_fiscal_year=FY):
filters = frappe._dict(
{
"company": "_Test Company",
"filter_based_on": "Fiscal Year",
"from_fiscal_year": from_fiscal_year,
"to_fiscal_year": to_fiscal_year,
"period_start_date": "2049-01-01",
"period_end_date": "2049-12-31",
"periodicity": "Yearly",
"accumulated_values": 0,
"presentation_currency": None,
}
)
return execute(filters)[1]
def make_account(self, name, parent, include_in_gross):
account = create_account(account_name=name, parent_account=parent, company="_Test Company")
frappe.db.set_value("Account", account, "include_in_gross", include_in_gross)
return account
def book_income(self, account, amount):
make_journal_entry(BANK, account, amount, posting_date=DATE, submit=True)
def book_expense(self, account, amount):
make_journal_entry(account, BANK, amount, posting_date=DATE, submit=True)
def report_row(self, data, account):
return next(row for row in data if row.get("account") == account)
def test_gross_profit_excludes_non_gross_accounts(self):
# reuse bootstrap accounts for the non-gross (include_in_gross = 0) side
gross_income = self.make_account("_Test GNP Gross Income", INCOME_PARENT, include_in_gross=1)
gross_expense = self.make_account("_Test GNP Gross Expense", EXPENSE_PARENT, include_in_gross=1)
self.book_income(gross_income, 10000)
self.book_income(NON_GROSS_INCOME, 2000)
self.book_expense(gross_expense, 4000)
self.book_expense(NON_GROSS_EXPENSE, 1000)
data = self.run_report()
# gross profit only counts include_in_gross accounts: 10000 - 4000
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 6000)
# net profit counts everything: (10000 + 2000) - (4000 + 1000)
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 7000)
def test_net_profit_equals_gross_when_all_included(self):
income = self.make_account("_Test GNP All Income", INCOME_PARENT, include_in_gross=1)
expense = self.make_account("_Test GNP All Expense", EXPENSE_PARENT, include_in_gross=1)
self.book_income(income, 9000)
self.book_expense(expense, 5000)
data = self.run_report()
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 4000)
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 4000)
def test_nothing_included_in_gross_when_no_entries(self):
# a fiscal year with no income/expense entries yields the placeholder row
data = self.run_report(
from_fiscal_year="_Test Fiscal Year 2048", to_fiscal_year="_Test Fiscal Year 2048"
)
self.assertEqual(data[0]["account"], "'Nothing is included in gross'")

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,
@@ -895,7 +900,11 @@ class GrossProfitGenerator:
if row.cost_center:
query = query.where(purchase_invoice_item.cost_center == row.cost_center)
query = query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc).limit(1)
query = (
query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc)
.orderby(purchase_invoice.name, order=frappe.qb.desc)
.limit(1)
)
last_purchase_rate = query.run()
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
@@ -956,6 +965,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,
@@ -1136,6 +1146,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,
@@ -1174,6 +1185,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

@@ -700,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

@@ -84,7 +84,8 @@ def build_query_filters(filters: dict | None = None) -> list:
qb_filters = []
if filters:
if filters.account:
qb_filters.append(qb.Field("account").isin(filters.account))
accounts = filters.account if isinstance(filters.account, list | tuple) else [filters.account]
qb_filters.append(qb.Field("account").isin(accounts))
if filters.voucher_no:
qb_filters.append(qb.Field("voucher_no").eq(filters.voucher_no))

View File

@@ -0,0 +1,152 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import qb
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.invalid_ledger_entries.invalid_ledger_entries import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestInvalidLedgerEntries(ERPNextTestSuite):
"""Tests for the Invalid Ledger Entries integrity report.
The report flags vouchers that still have *active* ledger entries
(GL Entry with is_cancelled=0 or Payment Ledger Entry with delinked=0)
in the given period, but whose source voucher document is no longer
submitted (docstatus != 1). Such orphaned ledgers indicate corruption.
"""
def setUp(self):
self.company = "_Test Company"
self.debit_account = "_Test Bank - _TC"
self.credit_account = "_Test Cash - _TC"
self.from_date = "2026-01-01"
self.to_date = "2026-12-31"
self.posting_date = "2026-06-01"
def run_report(self, **extra):
filters = frappe._dict(
{
"company": self.company,
"from_date": self.from_date,
"to_date": self.to_date,
}
)
filters.update(extra)
return execute(filters)[1]
def make_submitted_jv(self):
return make_journal_entry(
self.debit_account,
self.credit_account,
amount=500,
posting_date=self.posting_date,
company=self.company,
submit=True,
)
def test_healthy_voucher_not_flagged(self):
"""A normal balanced, submitted Journal Entry must NOT be flagged."""
jv = self.make_submitted_jv()
# It genuinely posted active GL entries, so it is in scope of the scan.
self.assertTrue(
frappe.db.exists(
"GL Entry",
{"voucher_no": jv.name, "is_cancelled": 0, "company": self.company},
)
)
flagged = {row.get("voucher_no") for row in self.run_report()}
self.assertNotIn(jv.name, flagged)
def test_orphaned_gl_entries_flagged(self):
"""A voucher whose document was set non-submitted while its GL entries
remain active (is_cancelled=0) must be flagged as invalid."""
jv = self.make_submitted_jv()
# Corrupt the state: mark the source document as cancelled (docstatus=2)
# without cancelling/removing its GL Entries. This is the exact orphaned
# ledger condition the report detects.
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
data = self.run_report()
matching = [
row
for row in data
if row.get("voucher_no") == jv.name and row.get("voucher_type") == "Journal Entry"
]
self.assertEqual(len(matching), 1, "Orphaned voucher should be flagged exactly once")
self.assertEqual(matching[0]["voucher_type"], "Journal Entry")
self.assertEqual(matching[0]["voucher_no"], jv.name)
def test_voucher_no_filter_scopes_scan(self):
"""The voucher_no filter must restrict the scan to that voucher only."""
orphan = self.make_submitted_jv()
other = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
frappe.db.set_value("Journal Entry", other.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report(voucher_no=orphan.name)}
self.assertIn(orphan.name, flagged)
self.assertNotIn(other.name, flagged)
def test_account_filter_scopes_scan(self):
"""The account filter (a MultiSelectList, so a list) must restrict the
scan to vouchers touching one of the given accounts."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
# Filtering on an account the voucher touches -> flagged.
flagged = {row.get("voucher_no") for row in self.run_report(account=[self.debit_account])}
self.assertIn(orphan.name, flagged)
# Filtering on an unrelated account -> not in scope.
unrelated = "Creditors - _TC"
flagged = {row.get("voucher_no") for row in self.run_report(account=[unrelated])}
self.assertNotIn(orphan.name, flagged)
def test_account_filter_accepts_a_scalar(self):
"""A scalar (non-list) account filter must not crash the query."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report(account=self.debit_account)}
self.assertIn(orphan.name, flagged)
def test_period_filter_excludes_out_of_range(self):
"""Vouchers posted outside the from/to window must not be scanned."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
flagged = {
row.get("voucher_no") for row in self.run_report(from_date="2025-01-01", to_date="2025-12-31")
}
self.assertNotIn(orphan.name, flagged)
def test_cancelled_gl_entries_not_flagged(self):
"""If the ledger entries are properly cancelled (is_cancelled=1), the
voucher is out of scope even when its document is non-submitted."""
jv = self.make_submitted_jv()
gle = qb.DocType("GL Entry")
qb.update(gle).set(gle.is_cancelled, 1).where(gle.voucher_no == jv.name).run()
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report()}
self.assertNotIn(jv.name, flagged)
def test_missing_filters_raises(self):
"""validate_filters must guard mandatory inputs."""
self.assertRaises(frappe.ValidationError, execute, None)
bad = frappe._dict({"from_date": self.from_date, "to_date": self.to_date})
self.assertRaises(frappe.ValidationError, execute, bad)
reversed_dates = frappe._dict(
{"company": self.company, "from_date": self.to_date, "to_date": self.from_date}
)
self.assertRaises(frappe.ValidationError, execute, reversed_dates)

View File

@@ -21,6 +21,13 @@ def execute(filters=None):
entries = get_entries(filters)
invoice_details = get_invoice_posting_date_map(filters)
# Only four range columns are defined (range1-range4, the last being "90 Above").
# Three thresholds yield exactly four buckets, so payments more than 90 days after
# the invoice land in range4 instead of an unread range5.
report_filters = frappe._dict(filters)
report_filters.range = "30, 60, 90"
report = ReceivablePayableReport(report_filters)
data = []
for d in entries:
invoice = invoice_details.get(d.against_voucher_no) or frappe._dict()
@@ -29,7 +36,9 @@ def execute(filters=None):
d.update({"range1": 0, "range2": 0, "range3": 0, "range4": 0, "outstanding": payment_amount})
if d.against_voucher_no:
ReceivablePayableReport(filters).get_ageing_data(invoice.posting_date, d)
# age the payment by how long after the invoice it was made (payment date - invoice date)
report.age_as_on = getdate(d.posting_date)
report.get_ageing_data(invoice.posting_date, d)
row = [
d.voucher_type,

View File

@@ -0,0 +1,140 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import getdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.payment_period_based_on_invoice_date.payment_period_based_on_invoice_date import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
"""Depth tests for the Payment Period Based On Invoice Date report.
The report lists Payment Ledger Entries against invoices and buckets the paid
amount by the payment period -- how long after the invoice the payment was made
(payment date - invoice date) -- into ranges: range1 (0-30), range2 (30-60),
range3 (60-90), range4 (90 Above).
"""
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"payment_type": "Incoming",
"party_type": "Customer",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
}
)
filters.update(extra)
columns, data = execute(filters)
fieldnames = [c["fieldname"] for c in columns]
# Map each positional row to a dict keyed by column fieldname so assertions
# stay correct even if a column is inserted or reordered.
return columns, [dict(zip(fieldnames, row, strict=False)) for row in data]
def find_payment_row(self, data, payment_name):
for row in data:
if row["payment_entry"] == payment_name:
return row
return None
def pay_invoice(self, invoice, payment_date):
pe = get_payment_entry("Sales Invoice", invoice.name)
pe.posting_date = payment_date
pe.reference_no = "1"
pe.reference_date = payment_date
pe.submit()
return pe
def test_paid_amount_lands_in_0_30_bucket(self):
# invoice 2026-06-01, paid 2026-06-20 -> 19 days after -> 0-30 bucket
invoice = create_sales_invoice(customer="_Test Customer", rate=1000, posting_date="2026-06-01")
payment = self.pay_invoice(invoice, "2026-06-20")
_columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
self.assertEqual(row["party_type"], "Customer")
self.assertEqual(row["posting_date"], getdate("2026-06-20"))
self.assertEqual(row["invoice"], invoice.name)
self.assertEqual(row["invoice_posting_date"], getdate("2026-06-01"))
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["age"], 19) # age = payment date - invoice date
# Buckets: 0-30 filled, others empty.
self.assertEqual(row["range1"], 1000) # 0-30
self.assertEqual(row["range2"], 0) # 30-60
self.assertEqual(row["range3"], 0) # 60-90
self.assertEqual(row["range4"], 0) # 90 Above
def test_paid_amount_lands_in_30_60_bucket(self):
# invoice 2026-06-01, paid 2026-07-16 -> 45 days after -> 30-60 bucket
invoice = create_sales_invoice(customer="_Test Customer 1", rate=1000, posting_date="2026-06-01")
payment = self.pay_invoice(invoice, "2026-07-16")
_columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["age"], 45)
# Buckets: 30-60 filled, others empty.
self.assertEqual(row["range1"], 0)
self.assertEqual(row["range2"], 1000)
self.assertEqual(row["range3"], 0)
self.assertEqual(row["range4"], 0)
def test_payment_over_90_days_lands_in_90_above_bucket(self):
# invoice 2026-01-01, paid 2026-06-01 -> 151 days after -> "90 Above" bucket.
# Regression guard: with four range columns, a payment older than the last
# threshold must fall into range4 rather than an unread range5 (showing 0).
invoice = create_sales_invoice(customer="_Test Customer 2", rate=1000, posting_date="2026-01-01")
payment = self.pay_invoice(invoice, "2026-06-01")
_columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["age"], 151)
self.assertEqual(row["range1"], 0)
self.assertEqual(row["range2"], 0)
self.assertEqual(row["range3"], 0)
self.assertEqual(row["range4"], 1000) # 90 Above captures the full amount
def test_columns_expose_expected_age_buckets(self):
columns, _data = self.run_report()
labels_by_fieldname = {c["fieldname"]: c["label"] for c in columns}
self.assertEqual(labels_by_fieldname["range1"], "0-30")
self.assertEqual(labels_by_fieldname["range2"], "30-60")
self.assertEqual(labels_by_fieldname["range3"], "60-90")
self.assertEqual(labels_by_fieldname["range4"], "90 Above")
# Sales Invoice link for Incoming payments.
invoice_col = next(c for c in columns if c["fieldname"] == "invoice")
self.assertEqual(invoice_col["options"], "Sales Invoice")
def test_invalid_payment_type_party_type_combo_throws(self):
# Incoming + Supplier is invalid.
self.assertRaises(
frappe.ValidationError,
self.run_report,
payment_type="Incoming",
party_type="Supplier",
)
# Outgoing + Customer is invalid.
self.assertRaises(
frappe.ValidationError,
self.run_report,
payment_type="Outgoing",
party_type="Customer",
)

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 2,
"is_standard": "Yes",
"modified": "2026-06-22 13:38:15.898375",
"modified": "2026-07-01 13:36:14.934965",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -207,7 +207,7 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
return chart
def execute_synced_report(filters):
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):

View File

@@ -0,0 +1,107 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.profitability_analysis.profitability_analysis import execute
from erpnext.tests.utils import ERPNextTestSuite
INCOME = "Sales - _TC"
EXPENSE = "_Test Account Cost for Goods Sold - _TC"
BANK = "_Test Bank - _TC"
class TestProfitabilityAnalysis(ERPNextTestSuite):
def run_report(self, fiscal_year="_Test Fiscal Year 2026", **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"based_on": "Cost Center",
"fiscal_year": fiscal_year,
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)[1]
def make_cc(self, name, **args):
create_cost_center(cost_center_name=name, **args)
return name + " - _TC"
def row(self, data, account):
return next(r for r in data if r.get("account") == account)
def book_income(self, cost_center, amount, posting_date="2026-06-01"):
create_sales_invoice(
cost_center=cost_center, income_account=INCOME, rate=amount, qty=1, posting_date=posting_date
)
def book_expense(self, cost_center, amount, posting_date="2026-06-01"):
make_journal_entry(
EXPENSE, BANK, amount, cost_center=cost_center, posting_date=posting_date, submit=True
)
def test_income_expense_and_gross_profit(self):
# a dedicated leaf cost center keeps these exact assertions free of GL that
# other tests may book against a shared cost center in the same fiscal year
cc = self.make_cc("_Test PA Income Expense")
self.book_income(cc, 10000)
self.book_expense(cc, 4000)
row = self.row(self.run_report(), cc)
self.assertEqual(row["income"], 10000)
self.assertEqual(row["expense"], 4000)
self.assertEqual(row["gross_profit_loss"], 6000)
def test_parent_cost_center_accumulates_children(self):
parent = self.make_cc("_Test PA Parent", is_group=1)
child_1 = self.make_cc("_Test PA Child 1", parent_cost_center=parent)
child_2 = self.make_cc("_Test PA Child 2", parent_cost_center=parent)
self.book_income(child_1, 10000)
self.book_expense(child_2, 3000)
data = self.run_report()
self.assertEqual(self.row(data, child_1)["income"], 10000)
self.assertEqual(self.row(data, child_2)["expense"], 3000)
parent_row = self.row(data, parent)
self.assertEqual(parent_row["income"], 10000)
self.assertEqual(parent_row["expense"], 3000)
self.assertEqual(parent_row["gross_profit_loss"], 7000)
def test_date_range_excludes_out_of_period_entries(self):
cc = self.make_cc("_Test PA Date Range")
self.book_income(cc, 10000, posting_date="2025-06-01")
# the 2025 income must not appear in a 2026 report (zero-value rows are dropped)
accounts_2026 = {r.get("account") for r in self.run_report()}
self.assertNotIn(cc, accounts_2026)
row_2025 = self.row(
self.run_report(
fiscal_year="_Test Fiscal Year 2025", from_date="2025-01-01", to_date="2025-12-31"
),
cc,
)
self.assertEqual(row_2025["income"], 10000)
def test_total_row_sums_income_and_expense(self):
cc = "_Test Cost Center - _TC"
self.book_income(cc, 10000)
self.book_expense(cc, 4000)
data = self.run_report()
# the report appends a blank separator row and a totals row at the end
total_row = data[-1]
# the report wraps the (possibly translated) "Total" label in single quotes
self.assertEqual(total_row["account"], "'" + frappe._("Total") + "'")
# total is built from direct (non-accumulated) values, so it stays internally consistent
self.assertEqual(total_row["gross_profit_loss"], total_row["income"] - total_row["expense"])
# and it includes this test's bookings
self.assertGreaterEqual(total_row["income"], 10000)
self.assertGreaterEqual(total_row["expense"], 4000)

View File

@@ -0,0 +1,171 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.purchase_invoice_trends.purchase_invoice_trends import execute
from erpnext.tests.utils import ERPNextTestSuite
FISCAL_YEAR = "_Test Fiscal Year 2026"
COMPANY = "_Test Company"
SUPPLIER = "_Test Supplier"
ITEM = "_Test Item"
POSTING_DATE = "2026-06-01"
def make_dated_purchase_invoice(qty, rate):
# make_purchase_invoice ignores posting_date unless posting time is explicitly set, so build the
# invoice unsubmitted, pin the posting date, then submit to land it in the intended period bucket.
pi = make_purchase_invoice(
supplier=SUPPLIER, item_code=ITEM, qty=qty, rate=rate, posting_date=POSTING_DATE, do_not_submit=1
)
pi.set_posting_time = 1
pi.posting_date = POSTING_DATE
pi.submit()
return pi
class TestPurchaseInvoiceTrends(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": COMPANY,
"fiscal_year": FISCAL_YEAR,
"period": "Yearly",
"based_on": "Item",
}
)
filters.update(extra)
columns, data = execute(filters)
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
return labels, data
@staticmethod
def _cell(labels, row, label):
return row[labels.index(label)]
def _find_row(self, data, key):
for row in data:
if row and row[0] == key:
return row
return None
def test_yearly_item_qty_and_amount(self):
labels_before, data_before = self.run_report()
before = self._find_row(data_before, ITEM)
qty, rate = 4, 250
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report()
self.assertIn("Item", labels)
self.assertIn("Item Name", labels)
self.assertIn("Currency", labels)
self.assertIn("Total(Qty)", labels)
self.assertIn("Total(Amt)", labels)
# Yearly period bucket uses the fiscal year name as the label prefix
self.assertIn(f"{FISCAL_YEAR} (Qty)", labels)
self.assertIn(f"{FISCAL_YEAR} (Amt)", labels)
row = self._find_row(data, ITEM)
self.assertIsNotNone(row)
before_qty = self._cell(labels_before, before, f"{FISCAL_YEAR} (Qty)") if before else 0
before_amt = self._cell(labels_before, before, f"{FISCAL_YEAR} (Amt)") if before else 0
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Amt)") - before_amt, qty * rate)
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_monthly_bucket(self):
labels_before, data_before = self.run_report(period="Monthly")
before = self._find_row(data_before, ITEM)
qty, rate = 3, 100
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(period="Monthly")
# posting_date 2026-06-01 -> June bucket
self.assertIn("Jun (Qty)", labels)
self.assertIn("Jun (Amt)", labels)
row = self._find_row(data, ITEM)
before_qty = self._cell(labels_before, before, "Jun (Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Jun (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_quarterly_bucket(self):
labels_before, data_before = self.run_report(period="Quarterly")
before = self._find_row(data_before, ITEM)
qty, rate = 2, 150
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(period="Quarterly")
# 2026-06-01 falls in the Apr-Jun quarter
self.assertIn("Apr-Jun (Qty)", labels)
self.assertIn("Apr-Jun (Amt)", labels)
row = self._find_row(data, ITEM)
before_qty = self._cell(labels_before, before, "Apr-Jun (Qty)") if before else 0
before_amt = self._cell(labels_before, before, "Apr-Jun (Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Apr-Jun (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, "Apr-Jun (Amt)") - before_amt, qty * rate)
def test_based_on_supplier(self):
labels_before, data_before = self.run_report(based_on="Supplier")
before = self._find_row(data_before, SUPPLIER)
qty, rate = 5, 200
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(based_on="Supplier")
self.assertIn("Supplier", labels)
self.assertIn("Supplier Name", labels)
self.assertIn("Supplier Group", labels)
row = self._find_row(data, SUPPLIER)
self.assertIsNotNone(row)
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_group_by_item_under_supplier(self):
labels_before, data_before = self.run_report(based_on="Supplier", group_by="Item")
# group_by inserts an "Item" column; the item breakdown row carries the item key there
item_idx = labels_before.index("Item")
before = None
for r in data_before:
if r and r[0] != SUPPLIER and r[item_idx] == ITEM:
before = r
break
qty, rate = 6, 300
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(based_on="Supplier", group_by="Item")
self.assertIn("Item", labels)
item_idx = labels.index("Item")
row = None
for r in data:
if r and r[0] != SUPPLIER and r[0] != "'Total'" and r[item_idx] == ITEM:
row = r
break
self.assertIsNotNone(row)
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)

View File

@@ -0,0 +1,101 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.received_items_to_be_billed.received_items_to_be_billed import execute
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice as make_pi_from_pr
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite
class TestReceivedItemsToBeBilled(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"posting_date": "2026-06-30",
}
)
filters.update(extra)
return execute(filters)[1]
def get_row(self, data, purchase_receipt):
matches = [row for row in data if row.get("name") == purchase_receipt]
return matches[0] if matches else None
def test_unbilled_receipt_appears_with_pending_amount(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
row = self.get_row(self.run_report(), pr.name)
self.assertIsNotNone(row, "Unbilled Purchase Receipt should appear in the report")
self.assertEqual(row.get("supplier"), "_Test Supplier")
self.assertEqual(row.get("item_code"), "_Test Item")
self.assertEqual(row.get("amount"), 1000.0)
self.assertEqual(row.get("billed_amount"), 0.0)
self.assertEqual(row.get("returned_amount"), 0.0)
self.assertEqual(row.get("pending_amount"), 1000.0)
def test_billed_receipt_drops_out_of_report(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
self.assertIsNotNone(self.get_row(self.run_report(), pr.name))
pi = make_pi_from_pr(pr.name)
pi.set_posting_time = 1
pi.posting_date = "2026-06-02"
pi.submit()
self.assertIsNone(
self.get_row(self.run_report(), pr.name),
"Fully billed Purchase Receipt should no longer appear in the report",
)
def test_reference_field_filter_limits_to_single_receipt(self):
first_pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
second_pr = make_purchase_receipt(
item_code="_Test Item",
qty=3,
rate=100,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
data = self.run_report(purchase_receipt=first_pr.name)
self.assertIsNotNone(self.get_row(data, first_pr.name))
self.assertIsNone(self.get_row(data, second_pr.name))
def test_posting_date_cutoff_excludes_later_receipts(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-15",
)
self.assertIsNone(
self.get_row(self.run_report(posting_date="2026-06-01"), pr.name),
"Receipt dated after the cutoff should be excluded",
)
self.assertIsNotNone(self.get_row(self.run_report(posting_date="2026-06-30"), pr.name))

View File

@@ -0,0 +1,118 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_invoice_trends.sales_invoice_trends import execute
from erpnext.tests.utils import ERPNextTestSuite
FISCAL_YEAR = "_Test Fiscal Year 2026"
POSTING_DATE = "2026-06-01"
class TestSalesInvoiceTrends(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": FISCAL_YEAR,
"based_on": "Item",
"period": "Yearly",
}
)
filters.update(extra)
columns, data = execute(filters)
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
return labels, data
def _cell(self, data, key_label, key_value, col_label, labels):
"""Return the value at column `col_label` for the row whose first-column
value equals `key_value`, or 0 if that row does not exist yet."""
key_idx = labels.index(key_label)
col_idx = labels.index(col_label)
for row in data:
if row[key_idx] == key_value:
return row[col_idx] or 0
return 0
def test_yearly_item_amount_and_total(self):
# Yearly period => a single "<FY> (Qty)"/"(Amt)" bucket, plus Total(Qty)/Total(Amt).
labels, before = self.run_report()
qty_col = f"{FISCAL_YEAR} (Qty)"
amt_col = f"{FISCAL_YEAR} (Amt)"
before_qty = self._cell(before, "Item", "_Test Item", qty_col, labels)
before_amt = self._cell(before, "Item", "_Test Item", amt_col, labels)
before_tot_qty = self._cell(before, "Item", "_Test Item", "Total(Qty)", labels)
before_tot_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(item="_Test Item", qty=4, rate=200, posting_date=POSTING_DATE)
labels, after = self.run_report()
self.assertEqual(self._cell(after, "Item", "_Test Item", qty_col, labels) - before_qty, 4)
self.assertEqual(self._cell(after, "Item", "_Test Item", amt_col, labels) - before_amt, 800)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Qty)", labels) - before_tot_qty, 4)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot_amt, 800)
def test_monthly_lands_in_june_bucket(self):
# Monthly period => one bucket per month; a 2026-06-01 invoice hits "Jun (Qty)"/"(Amt)".
labels, before = self.run_report(period="Monthly")
before_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels)
before_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels)
before_tot = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(item="_Test Item", qty=3, rate=100, posting_date=POSTING_DATE)
labels, after = self.run_report(period="Monthly")
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_qty, 3)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_amt, 300)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot, 300)
# Nothing should leak into an unrelated month.
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan (Amt)", labels), 0)
def test_quarterly_lands_in_apr_jun_bucket(self):
# Quarterly period over a Jan-Dec fiscal year => Apr-Jun is the 2nd quarter; June lands there.
labels, before = self.run_report(period="Quarterly")
before_qty = self._cell(before, "Item", "_Test Item", "Apr-Jun (Qty)", labels)
before_amt = self._cell(before, "Item", "_Test Item", "Apr-Jun (Amt)", labels)
create_sales_invoice(item="_Test Item", qty=5, rate=50, posting_date=POSTING_DATE)
labels, after = self.run_report(period="Quarterly")
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Qty)", labels) - before_qty, 5)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Amt)", labels) - before_amt, 250)
# Jan-Mar quarter must stay untouched.
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan-Mar (Amt)", labels), 0)
def test_based_on_customer_total(self):
# based_on=Customer => first column is "Customer"; the customer's Total(Amt) reflects the sale.
labels, before = self.run_report(based_on="Customer")
before_tot_qty = self._cell(before, "Customer", "_Test Customer", "Total(Qty)", labels)
before_tot_amt = self._cell(before, "Customer", "_Test Customer", "Total(Amt)", labels)
create_sales_invoice(
customer="_Test Customer", item="_Test Item", qty=2, rate=300, posting_date=POSTING_DATE
)
labels, after = self.run_report(based_on="Customer")
self.assertEqual(
self._cell(after, "Customer", "_Test Customer", "Total(Qty)", labels) - before_tot_qty, 2
)
self.assertEqual(
self._cell(after, "Customer", "_Test Customer", "Total(Amt)", labels) - before_tot_amt, 600
)
def test_group_by_item_under_customer(self):
# based_on=Customer + group_by=Item inserts an "Item" breakdown column before the period
# buckets; the per-item detail row carries the item key and the amount for that customer/item.
labels, before = self.run_report(based_on="Customer", group_by="Item")
# In group_by mode the detail rows key off the group_by column ("Item"), so snapshot by item.
before_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(
customer="_Test Customer", item="_Test Item", qty=6, rate=100, posting_date=POSTING_DATE
)
labels, after = self.run_report(based_on="Customer", group_by="Item")
self.assertIn("Item", labels)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_amt, 600)

View File

@@ -15,8 +15,6 @@ def execute(filters=None):
columns = get_columns(filters)
filters.get("date")
data = []
if not filters.get("shareholder"):
@@ -24,7 +22,7 @@ def execute(filters=None):
else:
share_type, no_of_shares, rate, amount = 1, 2, 3, 4
all_shares = get_all_shares(filters.get("shareholder"))
all_shares = get_all_shares(filters.get("shareholder"), filters.get("date"), filters.get("company"))
for share_entry in all_shares:
row = False
for datum in data:
@@ -63,5 +61,47 @@ def get_columns(filters):
return columns
def get_all_shares(shareholder):
return frappe.get_doc("Shareholder", shareholder).share_balance
def get_all_shares(shareholder, date, company=None):
"""Share movements for the shareholder up to (and including) `date`, signed by direction:
shares received are positive, shares transferred/sold out are negative.
The shareholder and company predicates are pushed into the query so only the
relevant transfers are fetched instead of scanning the whole table."""
share_transfer = frappe.qb.DocType("Share Transfer")
query = (
frappe.qb.from_(share_transfer)
.select(
share_transfer.share_type,
share_transfer.no_of_shares,
share_transfer.rate,
share_transfer.amount,
share_transfer.from_shareholder,
share_transfer.to_shareholder,
)
.where((share_transfer.docstatus == 1) & (share_transfer.date <= date))
.where(
(share_transfer.to_shareholder == shareholder) | (share_transfer.from_shareholder == shareholder)
)
.orderby(share_transfer.date)
)
if company:
query = query.where(share_transfer.company == company)
transfers = query.run(as_dict=True)
shares = []
for transfer in transfers:
if transfer.to_shareholder == shareholder:
shares.append(transfer)
elif transfer.from_shareholder == shareholder:
shares.append(
frappe._dict(
share_type=transfer.share_type,
no_of_shares=-transfer.no_of_shares,
rate=transfer.rate,
amount=-transfer.amount,
)
)
return shares

View File

@@ -0,0 +1,201 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.share_balance.share_balance import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestShareBalanceReport(ERPNextTestSuite):
def setUp(self):
self.share_type = create_share_type("_Test Share Balance Equity")
self.shareholder = create_shareholder("_Test Share Balance Holder", COMPANY)
def test_date_filter_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"shareholder": self.shareholder}))
def test_no_shareholder_returns_empty_data(self):
# `shareholder` is optional; without it the report yields no rows.
columns, data = execute(frappe._dict({"date": "2026-06-01", "company": COMPANY}))
self.assertEqual(data, [])
self.assertEqual(len(columns), 5)
def test_balance_after_issue(self):
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
row = self.get_row(date="2026-06-05")
self.assertEqual(row[0], self.shareholder)
self.assertEqual(row[1], self.share_type)
self.assertEqual(row[2], 100) # no_of_shares
self.assertEqual(row[3], 10) # average rate
self.assertEqual(row[4], 1000) # amount = 100 * 10
def test_company_filter_scopes_transfers(self):
# the transfer is booked under `_Test Company`
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
# matching company: the holding shows up
self.assertEqual(self.get_row(date="2026-06-05")[2], 100)
# a different company must not surface this shareholder's transfer
other_company_data = execute(
frappe._dict(
{"date": "2026-06-05", "company": "_Test Company 1", "shareholder": self.shareholder}
)
)[1]
self.assertEqual(other_company_data, [])
def test_balance_increases_on_second_issue(self):
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=101,
to_no=200,
no_of_shares=100,
rate=20,
date="2026-06-10",
)
# The report groups by share type, summing shares and amount and
# recomputing the average rate: (1000 + 2000) / 200 = 15.
row = self.get_row(date="2026-06-15")
self.assertEqual(row[2], 200)
self.assertEqual(row[3], 15)
self.assertEqual(row[4], 3000)
def test_balance_reduces_after_transfer_out(self):
other_holder = create_shareholder("_Test Share Balance Holder 2", COMPANY)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Transfer",
from_shareholder=self.shareholder,
to_shareholder=other_holder,
share_type=self.share_type,
from_no=1,
to_no=40,
no_of_shares=40,
rate=10,
date="2026-06-10",
)
row = self.get_row(date="2026-06-15")
self.assertEqual(row[2], 60) # 100 issued - 40 transferred out
self.assertEqual(row[4], 600)
other_row = self.get_row(date="2026-06-15", shareholder=other_holder)
self.assertEqual(other_row[2], 40)
self.assertEqual(other_row[4], 400)
def test_as_on_date_before_issue_shows_no_holding(self):
# the report is as-on `date`: before any share transfer, the shareholder holds nothing
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
data = execute(
frappe._dict({"date": "2026-05-01", "company": COMPANY, "shareholder": self.shareholder})
)[1]
self.assertEqual(data, [])
def test_as_on_date_reflects_holding_up_to_that_date(self):
# two issues on different dates; an as-on date between them sees only the first
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=101,
to_no=200,
no_of_shares=100,
rate=20,
date="2026-06-10",
)
self.assertEqual(self.get_row(date="2026-06-05")[2], 100) # only the first issue
self.assertEqual(self.get_row(date="2026-06-15")[2], 200) # both issues
def get_row(self, date, shareholder=None):
filters = frappe._dict(
{"date": date, "company": COMPANY, "shareholder": shareholder or self.shareholder}
)
data = execute(filters)[1]
holdings = [r for r in data if r[1] == self.share_type]
self.assertEqual(len(holdings), 1, f"Expected one row for share type, got: {data}")
return holdings[0]
def create_share_type(title):
if not frappe.db.exists("Share Type", title):
frappe.get_doc({"doctype": "Share Type", "title": title}).insert()
return title
def create_shareholder(title, company):
shareholder = frappe.get_doc({"doctype": "Shareholder", "title": title, "company": company}).insert()
return shareholder.name
def create_share_transfer(**kwargs):
kwargs.setdefault("company", COMPANY)
kwargs.setdefault("asset_account", "Cash - _TC")
kwargs.setdefault("equity_or_liability_account", "Creditors - _TC")
transfer = frappe.get_doc({"doctype": "Share Transfer", **kwargs})
transfer.submit()
return transfer

View File

@@ -0,0 +1,171 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.share_ledger.share_ledger import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
# The report returns legacy positional columns (no fieldnames); name the indices once
# here so a column reorder needs a single edit instead of silently shifting assertions.
COL_SHAREHOLDER = 0
COL_DATE = 1
COL_TRANSFER_TYPE = 2
COL_SHARE_TYPE = 3
COL_NO_OF_SHARES = 4
COL_RATE = 5
COL_AMOUNT = 6
COL_COMPANY = 7
COL_SHARE_TRANSFER = 8
class TestShareLedger(ERPNextTestSuite):
def setUp(self):
self.shareholder = self.create_shareholder("_Test Share Ledger Holder")
# Issue 100 shares on 2026-06-01, then another 50 on 2026-06-10.
self.first = self.issue_shares(date="2026-06-01", from_no=1, to_no=100, rate=10)
self.second = self.issue_shares(date="2026-06-10", from_no=101, to_no=150, rate=12)
def test_ledger_lists_all_transfers_upto_date(self):
data = self.run_report(shareholder=self.shareholder, date="2026-06-30")
self.assertEqual(len(data), 2)
first_row, second_row = data
self.assertEqual(first_row[COL_SHAREHOLDER], self.shareholder)
self.assertEqual(first_row[COL_DATE], frappe.utils.getdate("2026-06-01"))
self.assertEqual(first_row[COL_TRANSFER_TYPE], "Issue")
self.assertEqual(first_row[COL_SHARE_TYPE], "Equity")
self.assertEqual(first_row[COL_NO_OF_SHARES], 100)
self.assertEqual(first_row[COL_RATE], 10)
self.assertEqual(first_row[COL_AMOUNT], 1000)
self.assertEqual(first_row[COL_COMPANY], COMPANY)
self.assertEqual(first_row[COL_SHARE_TRANSFER], self.first)
self.assertEqual(second_row[COL_DATE], frappe.utils.getdate("2026-06-10"))
self.assertEqual(second_row[COL_NO_OF_SHARES], 50)
self.assertEqual(second_row[COL_RATE], 12)
self.assertEqual(second_row[COL_AMOUNT], 600)
self.assertEqual(second_row[COL_SHARE_TRANSFER], self.second)
def test_running_balance_of_shares(self):
data = self.run_report(shareholder=self.shareholder, date="2026-06-30")
# The ledger records each transfer's raw no_of_shares (always positive); it does
# not sign by direction. With only incoming "Issue" rows here, summing them is a
# valid running total. (Directional balances are the Share Balance report's job.)
running = 0
balances = []
for row in data:
running += row[COL_NO_OF_SHARES]
balances.append(running)
self.assertEqual(balances, [100, 150])
def test_as_on_date_between_transfers_shows_only_first(self):
data = self.run_report(shareholder=self.shareholder, date="2026-06-05")
self.assertEqual(len(data), 1)
self.assertEqual(data[0][COL_SHARE_TRANSFER], self.first)
self.assertEqual(data[0][COL_NO_OF_SHARES], 100)
def test_transfer_type_label_when_shareholder_is_seller(self):
buyer = self.create_shareholder("_Test Share Ledger Buyer")
transfer = self.make_transfer(
from_shareholder=self.shareholder,
to_shareholder=buyer,
date="2026-06-15",
from_no=1,
to_no=40,
rate=10,
)
row = self.transfer_row(self.run_report(shareholder=self.shareholder, date="2026-06-30"), transfer)
# seller side: the label names the counterparty it went "to"
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer to {buyer}")
def test_transfer_type_label_when_shareholder_is_buyer(self):
seller = self.create_shareholder("_Test Share Ledger Seller")
# the seller must own shares before it can transfer them
self.issue_shares(date="2026-06-12", from_no=201, to_no=300, rate=10, shareholder=seller)
transfer = self.make_transfer(
from_shareholder=seller,
to_shareholder=self.shareholder,
date="2026-06-15",
from_no=201,
to_no=240,
rate=10,
)
row = self.transfer_row(self.run_report(shareholder=self.shareholder, date="2026-06-30"), transfer)
# buyer side: the label names the counterparty it came "from"
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer from {seller}")
def test_missing_date_throws(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict(shareholder=self.shareholder))
def test_missing_shareholder_returns_no_rows(self):
data = self.run_report(date="2026-06-30")
self.assertEqual(data, [])
def run_report(self, **extra):
filters = frappe._dict({"company": COMPANY, **extra})
return execute(filters)[1]
def transfer_row(self, data, transfer_name):
row = next((r for r in data if r[COL_SHARE_TRANSFER] == transfer_name), None)
self.assertIsNotNone(row, f"Share Transfer {transfer_name} missing from ledger")
return row
def create_shareholder(self, title):
doc = frappe.get_doc(
{
"doctype": "Shareholder",
"title": title,
"company": COMPANY,
}
).insert()
return doc.name
def issue_shares(self, date, from_no, to_no, rate, shareholder=None):
doc = frappe.get_doc(
{
"doctype": "Share Transfer",
"transfer_type": "Issue",
"date": date,
"to_shareholder": shareholder or self.shareholder,
"share_type": "Equity",
"from_no": from_no,
"to_no": to_no,
"no_of_shares": to_no - from_no + 1,
"rate": rate,
"company": COMPANY,
"asset_account": "Cash - _TC",
"equity_or_liability_account": "Creditors - _TC",
}
)
doc.submit()
return doc.name
def make_transfer(self, from_shareholder, to_shareholder, date, from_no, to_no, rate):
doc = frappe.get_doc(
{
"doctype": "Share Transfer",
"transfer_type": "Transfer",
"date": date,
"from_shareholder": from_shareholder,
"to_shareholder": to_shareholder,
"share_type": "Equity",
"from_no": from_no,
"to_no": to_no,
"no_of_shares": to_no - from_no + 1,
"rate": rate,
"company": COMPANY,
"asset_account": "Cash - _TC",
"equity_or_liability_account": "Creditors - _TC",
}
)
doc.submit()
return doc.name

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
"modified": "2026-06-22 13:38:42.740436",
"modified": "2026-07-01 17:32:21.801141",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -583,7 +583,7 @@ def hide_group_accounts(data):
return non_group_accounts_data
def execute_synced_report(filters):
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):

View File

@@ -0,0 +1,63 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.voucher_wise_balance.voucher_wise_balance import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestVoucherWiseBalance(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
}
)
filters.update(extra)
return execute(filters)[1]
def find_row(self, data, voucher_no):
for row in data:
if row.get("voucher_no") == voucher_no:
return row
return None
def test_balanced_voucher_not_flagged(self):
jv = make_journal_entry(
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
)
data = self.run_report()
self.assertIsNone(
self.find_row(data, jv.name),
msg="A balanced voucher (debit == credit) must not be flagged.",
)
def test_imbalanced_voucher_flagged(self):
jv = make_journal_entry(
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
)
# Tamper one GL Entry: drop the debit side so debit != credit for this voucher.
gle_name = frappe.db.get_value(
"GL Entry",
{"voucher_no": jv.name, "is_cancelled": 0, "debit": [">", 0]},
"name",
)
self.assertIsNotNone(gle_name, msg="Expected a debit GL Entry for the journal entry.")
frappe.db.set_value("GL Entry", gle_name, {"debit": 400, "debit_in_account_currency": 400})
data = self.run_report()
row = self.find_row(data, jv.name)
self.assertIsNotNone(row, msg="An imbalanced voucher must be flagged by the report.")
self.assertEqual(row.get("voucher_type"), "Journal Entry")
self.assertEqual(row.get("credit"), 1000)
self.assertEqual(row.get("debit"), 400)
self.assertNotEqual(
row.get("debit"), row.get("credit"), msg="Flagged rows must have debit != credit."
)

View File

@@ -12,7 +12,6 @@ from frappe.utils import cint, flt, parse_json
import erpnext
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
ItemDetailsCtx,
_get_item_tax_template,
_get_item_tax_template_from_item_group,
get_item_tax_map,
@@ -350,7 +349,7 @@ def set_balance_in_account_currency(
def set_child_tax_template_and_map(item, child_item, parent_doc) -> None:
ctx = ItemDetailsCtx(
ctx = frappe._dict(
{
"item_code": item.item_code,
"posting_date": parent_doc.transaction_date,

View File

@@ -0,0 +1,329 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-14 12:44:31.994274",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "database",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Accounts Setup",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.138704",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Accounts Setup",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 55.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 0,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Chart of Accounts",
"link_to": "Account",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Chart of Cost Centers",
"link_to": "Cost Center",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Account Category",
"link_to": "Account Category",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounting Dimension",
"link_to": "Accounting Dimension",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency",
"link_to": "Currency",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency Exchange",
"link_to": "Currency Exchange",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Finance Book",
"link_to": "Finance Book",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Mode of Payment",
"link_to": "Mode of Payment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Term",
"link_to": "Payment Term",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Journal Entry Template",
"link_to": "Journal Entry Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Terms and Conditions",
"link_to": "Terms and Conditions",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Company",
"link_to": "Company",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Fiscal Year",
"link_to": "Fiscal Year",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Taxes",
"link_to": "Sales Taxes and Charges Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "lock-keyhole-open",
"indent": 1,
"keep_closed": 0,
"label": "Opening & Closing",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "COA Importer",
"link_to": "Chart of Accounts Importer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Opening Invoice Tool",
"link_to": "Opening Invoice Creation Tool",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Accounting Period",
"link_to": "Accounting Period",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "FX Revaluation",
"link_to": "Exchange Rate Revaluation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Period Closing Voucher",
"link_to": "Period Closing Voucher",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 1,
"keep_closed": 0,
"label": "Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Accounts Settings",
"link_to": "Accounts Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency Exchange Settings",
"link_to": "Currency Exchange Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Accounts Setup",
"type": "Workspace"
}

View File

@@ -0,0 +1,222 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-11 11:51:22.767176",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "circle-dollar-sign",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Banking",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.924019",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Banking",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 49.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "book-open-check",
"indent": 0,
"keep_closed": 0,
"label": "Bank Clearance",
"link_to": "Bank Clearance",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "tool",
"indent": 0,
"keep_closed": 0,
"label": "Bank Reconciliation",
"link_to": "Bank Reconciliation Tool",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "clipboard-check",
"indent": 0,
"keep_closed": 0,
"label": "Reconciliation Statement",
"link_to": "Bank Reconciliation Statement",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "split",
"indent": 0,
"keep_closed": 0,
"label": "Unreconcile Payment",
"link_to": "Unreconcile Payment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "link",
"indent": 0,
"keep_closed": 0,
"label": "Process Payment Reconciliation",
"link_to": "Process Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Bank",
"link_to": "Bank",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Bank Account",
"link_to": "Bank Account",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Bank Account Type",
"link_to": "Bank Account Type",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Bank Account Subtype",
"link_to": "Bank Account Subtype",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Bank Guarantee",
"link_to": "Bank Guarantee",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Plaid Settings",
"link_to": "Plaid Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "scroll-text",
"indent": 1,
"keep_closed": 1,
"label": "Dunning",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Dunning",
"link_to": "Dunning",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Dunning Type",
"link_to": "Dunning Type",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Banking",
"type": "Workspace"
}

View File

@@ -0,0 +1,104 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-14 14:38:20.315394",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Budgeting",
"link_type": "DocType",
"links": [],
"modified": "2026-07-02 04:24:48.116724",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budgeting",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 57.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "briefcase-business",
"indent": 0,
"keep_closed": 0,
"label": "Budget",
"link_to": "Budget",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "badge-cent",
"indent": 0,
"keep_closed": 0,
"label": "Cost Center",
"link_to": "Cost Center",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "accounting",
"indent": 0,
"keep_closed": 0,
"label": "Accounting Dimension",
"link_to": "Accounting Dimension",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "notepad-text",
"indent": 0,
"keep_closed": 0,
"label": "Cost Center Allocation",
"link_to": "Cost Center Allocation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "sheet",
"indent": 0,
"keep_closed": 0,
"label": "Budget Variance",
"link_to": "Budget Variance Report",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Budgeting",
"type": "Workspace"
}

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "table",
"icon": "sheet",
"idx": 1,
"indicator_color": "",
"is_hidden": 0,
@@ -266,9 +266,10 @@
"type": "Link"
}
],
"modified": "2026-05-18 09:49:45.138296",
"modified": "2026-06-14 13:44:08.095321",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Financial Reports",
"number_cards": [],
"owner": "Administrator",
@@ -279,6 +280,417 @@
"roles": [],
"sequence_id": 5.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "accounting",
"indent": 1,
"keep_closed": 0,
"label": "Financial Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Balance Sheet",
"link_to": "Balance Sheet",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Profit and Loss",
"link_to": "Profit and Loss Statement",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Cash Flow",
"link_to": "Cash Flow",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Trial Balance",
"link_to": "Trial Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Consolidated Report",
"link_to": "Consolidated Financial Statement",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Custom Financial Statement",
"link_to": "Custom Financial Statement",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Financial Report Template",
"link_to": "Financial Report Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "book-text",
"indent": 1,
"keep_closed": 0,
"label": "Ledgers",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "General Ledger",
"link_to": "General Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Ledger",
"link_to": "Customer Ledger Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Ledger",
"link_to": "Supplier Ledger Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 1,
"keep_closed": 1,
"label": "Registers",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Receivable",
"link_to": "Accounts Receivable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Payable",
"link_to": "Accounts Payable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "AR Summary",
"link_to": "Accounts Receivable Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "AP Summary",
"link_to": "Accounts Payable Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Register",
"link_to": "Sales Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Register",
"link_to": "Purchase Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item-wise sales Register",
"link_to": "Item-wise Sales Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item-wise Purchase Register",
"link_to": "Item-wise Purchase Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "dollar-sign",
"indent": 1,
"keep_closed": 1,
"label": "Profitability",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Gross Profit",
"link_to": "Gross Profit",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Profitability Analysis",
"link_to": "Profitability Analysis",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Invoice Trends",
"link_to": "Sales Invoice Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice Trends",
"link_to": "Purchase Invoice Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "scroll-text",
"indent": 1,
"keep_closed": 1,
"label": "Other Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Trial Balance for Party",
"link_to": "Trial Balance for Party",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Period Based On Invoice Date",
"link_to": "Payment Period Based On Invoice Date",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Partners Commission",
"link_to": "Sales Partners Commission",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Credit Balance",
"link_to": "Customer Credit Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Payment Summary",
"link_to": "Sales Payment Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Address And Contacts",
"link_to": "Address And Contacts",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "UAE VAT 201",
"link_to": "UAE VAT 201",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Financial Reports",
"type": "Workspace"
}

View File

@@ -587,9 +587,10 @@
"type": "Link"
}
],
"modified": "2026-01-23 11:05:47.246213",
"modified": "2026-06-14 13:44:08.471142",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Invoicing",
"number_cards": [
{
@@ -617,6 +618,354 @@
"roles": [],
"sequence_id": 2.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Invoicing",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Accounts",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "list-tree",
"indent": 0,
"keep_closed": 0,
"label": "Chart of Accounts",
"link_to": "Account",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "arrow-left-to-line",
"indent": 1,
"keep_closed": 0,
"label": "Receivables",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Customer",
"link_to": "Customer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Invoice",
"link_to": "Sales Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Credit Note",
"link_to": "Sales Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"route_options": "{\"is_return\": 1}",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Receivable",
"link_to": "Accounts Receivable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "arrow-right-from-line",
"indent": 1,
"keep_closed": 0,
"label": "Payables",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Supplier",
"link_to": "Supplier",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice",
"link_to": "Purchase Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Debit Note",
"link_to": "Purchase Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"route_options": "{\"is_return\": 1}",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Payable",
"link_to": "Accounts Payable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "money-coins-1",
"indent": 1,
"keep_closed": 0,
"label": "Payments",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Entry",
"link_to": "Payment Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Journal Entry",
"link_to": "Journal Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Request",
"link_to": "Payment Request",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Order",
"link_to": "Payment Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Reconciliation",
"link_to": "Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Unreconcile Payment",
"link_to": "Unreconcile Payment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Process Payment Reconciliation",
"link_to": "Process Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Accounting Ledger",
"link_to": "Repost Accounting Ledger",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Payment Ledger",
"link_to": "Repost Payment Ledger",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 0,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "General Ledger",
"link_to": "General Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Trial Balance",
"link_to": "Trial Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Financial Reports",
"link_to": "Financial Reports",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Accounts Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Invoicing",
"type": "Workspace"
}

View File

@@ -0,0 +1,240 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-11 11:51:21.886461",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "receipt-text",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Payments",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.184761",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Payments",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 47.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Payments",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "money-coins-1",
"indent": 1,
"keep_closed": 0,
"label": "Payments",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Entry",
"link_to": "Payment Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Journal Entry",
"link_to": "Journal Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Request",
"link_to": "Payment Request",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Order",
"link_to": "Payment Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Reconciliation",
"link_to": "Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Unreconcile Payment",
"link_to": "Unreconcile Payment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Process Payment Reconciliation",
"link_to": "Process Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Accounting Ledger",
"link_to": "Repost Accounting Ledger",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Payment Ledger",
"link_to": "Repost Payment Ledger",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Receivable",
"link_to": "Accounts Receivable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Payable",
"link_to": "Accounts Payable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "General Ledger",
"link_to": "General Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Trial Balance",
"link_to": "Trial Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Financial Reports",
"link_to": "Financial Reports",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Payments",
"type": "Workspace"
}

View File

@@ -0,0 +1,86 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-11 11:51:22.831729",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "money-coins-1",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Share Management",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:51.040978",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Share Management",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 50.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 1,
"collapsible": 1,
"icon": "customer",
"indent": 0,
"keep_closed": 0,
"label": "Shareholder",
"link_to": "Shareholder",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "move-horizontal",
"indent": 0,
"keep_closed": 0,
"label": "Share Transfer",
"link_to": "Share Transfer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "list",
"indent": 0,
"keep_closed": 0,
"label": "Share Ledger",
"link_to": "Share Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "notepad-text",
"indent": 0,
"keep_closed": 0,
"label": "Share Balance",
"link_to": "Share Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Share Management",
"type": "Workspace"
}

View File

@@ -0,0 +1,121 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-14 14:08:36.817393",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Subscriptions",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 14:08:36.999272",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscriptions",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 56.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "circle-dollar-sign",
"indent": 0,
"keep_closed": 0,
"label": "Subscription",
"link_to": "Subscription",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "receipt-text",
"indent": 0,
"keep_closed": 0,
"label": "Subscription Plan",
"link_to": "Subscription Plan",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Subscription Settings",
"link_to": "Subscription Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer",
"link_to": "Customer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier",
"link_to": "Supplier",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Subscriptions",
"type": "Workspace"
}

View File

@@ -0,0 +1,188 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-11 11:51:22.649582",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "money-coins-1",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Taxes",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.894825",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Taxes",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 48.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "panel-bottom-close",
"indent": 0,
"keep_closed": 0,
"label": "Sales Tax Template",
"link_to": "Sales Taxes and Charges Template",
"link_type": "DocType",
"navigate_to_tab": "",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "panel-top-close",
"indent": 0,
"keep_closed": 0,
"label": "Purchase Tax Template",
"link_to": "Purchase Taxes and Charges Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "stock",
"indent": 0,
"keep_closed": 0,
"label": "Item Tax Template",
"link_to": "Item Tax Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "triangle",
"indent": 0,
"keep_closed": 0,
"label": "Tax Category",
"link_to": "Tax Category",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "book-open-text",
"indent": 0,
"keep_closed": 0,
"label": "Tax Rule",
"link_to": "Tax Rule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "book-text",
"indent": 0,
"keep_closed": 0,
"label": "Tax Withholding Category",
"link_to": "Tax Withholding Category",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Tax Withholding Group",
"link_to": "Tax Withholding Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "notebook-text",
"indent": 0,
"keep_closed": 0,
"label": "Deduction Certificate",
"link_to": "Lower Deduction Certificate",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_to": "",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "TDS Computation Summary",
"link_to": "TDS Computation Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Tax Withholding Details",
"link_to": "Tax Withholding Details",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Taxes",
"type": "Workspace"
}

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)) {
@@ -727,6 +732,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

@@ -187,6 +187,7 @@ def make_depreciation_entry(
for d in depr_schedule_doc.get("depreciation_schedule")[
(sch_start_idx or 0) : (sch_end_idx or len(depr_schedule_doc.get("depreciation_schedule")))
]:
frappe.db.savepoint("depr_entry")
try:
_make_journal_entry_for_depreciation(
depr_schedule_doc,
@@ -202,6 +203,7 @@ def make_depreciation_entry(
accounting_dimensions,
)
except Exception as e:
frappe.db.rollback(save_point="depr_entry")
depr_posting_error = e
asset.reload()

View File

@@ -500,7 +500,7 @@ def get_target_item_details(item_code: str | None = None, company: str | None =
item_group_defaults = get_item_group_defaults(item.name, company)
brand_defaults = get_brand_defaults(item.name, company)
out.cost_center = get_default_cost_center(
ItemDetailsCtx({"item_code": item.name, "company": company}),
frappe._dict({"item_code": item.name, "company": company}),
item_defaults,
item_group_defaults,
brand_defaults,

View File

@@ -139,6 +139,7 @@ class AssetMovement(Document):
.select(asm_item.target_location, asm_item.to_employee)
.where((asm_item.asset == asset) & (asm.company == self.company) & (asm.docstatus == 1))
.orderby(asm.transaction_date, order=frappe.qb.desc)
.orderby(asm.name, order=frappe.qb.desc)
.limit(1)
.run()
)

View File

@@ -199,9 +199,10 @@
"type": "Link"
}
],
"modified": "2025-12-31 16:22:38.132729",
"modified": "2026-06-14 13:44:08.417956",
"modified_by": "Administrator",
"module": "Assets",
"module_onboarding": "Asset Onboarding",
"name": "Assets",
"number_cards": [],
"owner": "Administrator",
@@ -212,6 +213,294 @@
"roles": [],
"sequence_id": 7.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Assets",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Asset",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "laptop",
"indent": 0,
"keep_closed": 0,
"label": "Asset",
"link_to": "Asset",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "trending-down",
"indent": 0,
"keep_closed": 0,
"label": "Depreciation Schedule",
"link_to": "Asset Depreciation Schedule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sprout",
"indent": 0,
"keep_closed": 0,
"label": "Asset Capitalization",
"link_to": "Asset Capitalization",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "move-horizontal",
"indent": 0,
"keep_closed": 0,
"label": "Asset Movement",
"link_to": "Asset Movement",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "getting-started",
"indent": 1,
"keep_closed": 1,
"label": "Maintenance",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Maintenance Team",
"link_to": "Asset Maintenance Team",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Maintenance",
"link_to": "Asset Maintenance",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Maintenance Log",
"link_to": "Asset Maintenance Log",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Value Adjustment",
"link_to": "Asset Value Adjustment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Repair",
"link_to": "Asset Repair",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Fixed Asset Register",
"link_to": "Fixed Asset Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Depreciation Ledger",
"link_to": "Asset Depreciation Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Depreciations and Balances",
"link_to": "Asset Depreciations and Balances",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Maintenance",
"link_to": "Asset Maintenance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Activity",
"link_to": "Asset Activity",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Category",
"link_to": "Asset Category",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Location",
"link_to": "Location",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Accounts Settings",
"link_type": "DocType",
"navigate_to_tab": "assets_tab",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link",
"url": ""
}
],
"standard": 1,
"title": "Assets",
"type": "Workspace"
}

View File

@@ -232,9 +232,11 @@ def make_subcontracting_order(
target_doc.save()
if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
frappe.db.savepoint("submit_subcontracting_order")
try:
target_doc.submit()
except Exception as e:
frappe.db.rollback(save_point="submit_subcontracting_order")
target_doc.add_comment("Comment", _("Submit Action Failed") + "<br><br>" + str(e))
if notify:

View File

@@ -1531,11 +1531,11 @@ class TestPurchaseOrder(ERPNextTestSuite):
(via the standard item lookup the form uses) without going through
the Sales Order → Purchase Order mapping pipeline.
"""
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details
from erpnext.stock.get_item_details import get_item_details
item = make_item("_Test Drop Ship From Master", {"is_stock_item": 1, "delivered_by_supplier": 1})
ctx = ItemDetailsCtx(
ctx = frappe._dict(
{
"item_code": item.item_code,
"doctype": "Purchase Order",

View File

@@ -55,9 +55,16 @@ class SupplierScorecard(Document):
self.update_standing()
def on_update(self):
score = make_all_scorecards(self.name)
if score > 0:
self.save()
# Guard against recursion: the save() below re-enters on_update().
if self.flags.in_rescore:
return
if make_all_scorecards(self.name) > 0:
# New periods were created; re-save to refresh score and standings.
self.flags.in_rescore = True
try:
self.save()
finally:
self.flags.in_rescore = False
def validate_standings(self):
# Standings must form a continuous chain of bands covering 0 to 100 with no gaps or overlaps
@@ -405,16 +412,10 @@ def get_default_scorecard_standing():
def make_default_records():
install_variable_docs = get_default_scorecard_variables()
for d in install_variable_docs:
try:
d["doctype"] = "Supplier Scorecard Variable"
frappe.get_doc(d).insert()
except frappe.NameError:
pass
d["doctype"] = "Supplier Scorecard Variable"
frappe.get_doc(d).insert(ignore_if_duplicate=True)
install_standing_docs = get_default_scorecard_standing()
for d in install_standing_docs:
try:
d["doctype"] = "Supplier Scorecard Standing"
frappe.get_doc(d).insert()
except frappe.NameError:
pass
d["doctype"] = "Supplier Scorecard Standing"
frappe.get_doc(d).insert(ignore_if_duplicate=True)

View File

@@ -0,0 +1,130 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
create_pr_against_po,
create_purchase_order,
)
from erpnext.buying.report.item_wise_purchase_history.item_wise_purchase_history import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestItemWisePurchaseHistory(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)
def po_row(self, po_name, **extra):
data = self.run_report(**extra)[1]
return next(row for row in data if row["purchase_order"] == po_name)
def test_purchase_order_line_shown_with_values(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
row = self.po_row(po.name)
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["quantity"], 10)
self.assertEqual(row["rate"], 500)
self.assertEqual(row["amount"], 5000)
self.assertEqual(row["supplier"], "_Test Supplier")
def test_draft_purchase_order_excluded(self):
po = create_purchase_order(transaction_date="2026-06-01", do_not_submit=True)
names = {row["purchase_order"] for row in self.run_report()[1]}
self.assertNotIn(po.name, names)
def test_date_range_filters_on_transaction_date(self):
po = create_purchase_order(transaction_date="2026-06-01")
in_range = {
row["purchase_order"] for row in self.run_report(from_date="2026-05-01", to_date="2026-07-01")[1]
}
self.assertIn(po.name, in_range)
out_of_range = {
row["purchase_order"] for row in self.run_report(from_date="2026-01-01", to_date="2026-03-01")[1]
}
self.assertNotIn(po.name, out_of_range)
def test_item_code_filter(self):
po = create_purchase_order(
transaction_date="2026-06-01",
rm_items=[
{"item_code": "_Test Item", "qty": 5, "rate": 500, "warehouse": "_Test Warehouse - _TC"},
{"item_code": "_Test Item 2", "qty": 3, "rate": 200, "warehouse": "_Test Warehouse - _TC"},
],
)
rows = self.run_report(item_code="_Test Item 2")[1]
self.assertEqual({row["item_code"] for row in rows}, {"_Test Item 2"})
# the filtered-out line of the same order must not leak in
self.assertTrue(all(row["purchase_order"] == po.name for row in rows))
def test_item_group_filter(self):
# _Test Item is in _Test Item Group; _Test FG Item is in _Test Item Group Desktops
po_test_group = create_purchase_order(item_code="_Test Item", transaction_date="2026-06-01")
po_other_group = create_purchase_order(item_code="_Test FG Item", transaction_date="2026-06-01")
names = {row["purchase_order"] for row in self.run_report(item_group="_Test Item Group")[1]}
self.assertIn(po_test_group.name, names)
self.assertNotIn(po_other_group.name, names)
def test_supplier_filter(self):
create_purchase_order(supplier="_Test Supplier", transaction_date="2026-06-01")
create_purchase_order(supplier="_Test Supplier 1", transaction_date="2026-06-01")
suppliers = {row["supplier"] for row in self.run_report(supplier="_Test Supplier")[1]}
self.assertEqual(suppliers, {"_Test Supplier"})
def test_received_quantity_reflects_receipt(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
create_pr_against_po(po.name, received_qty=4)
self.assertEqual(self.po_row(po.name)["received_qty"], 4)
def test_billed_amount_reflects_invoice(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
pi = make_purchase_invoice(po.name)
pi.insert()
pi.submit()
self.assertEqual(self.po_row(po.name)["billed_amt"], 5000)
def test_amounts_reported_in_company_currency(self):
# a USD order must report rate/amount converted to the company's currency (base_* fields)
po = create_purchase_order(
do_not_save=True,
currency="USD",
qty=10,
rate=100,
transaction_date="2026-06-01",
)
po.conversion_rate = 80
po.insert()
po.submit()
row = self.po_row(po.name)
self.assertEqual(row["rate"], 8000) # 100 USD * 80
self.assertEqual(row["amount"], 80000) # 10 * 100 USD * 80
def test_chart_aggregates_amount_per_item(self):
create_purchase_order(item_code="_Test Item", qty=2, rate=500, transaction_date="2026-06-01")
create_purchase_order(item_code="_Test Item", qty=3, rate=500, transaction_date="2026-06-01")
chart = self.run_report(item_code="_Test Item")[3]
labels = chart["data"]["labels"]
values = chart["data"]["datasets"][0]["values"]
self.assertIn("_Test Item", labels)
# 2*500 + 3*500 aggregated for the item
self.assertEqual(values[labels.index("_Test Item")], 2500)

View File

@@ -0,0 +1,93 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_analytics.purchase_analytics import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
SUPPLIER = "_Test Supplier"
SUPPLIER_GROUP = "_Test Supplier Group"
# A historical window that ordinary test fixtures don't post into.
FROM_DATE = "2019-04-01"
TO_DATE = "2019-06-30"
class TestPurchaseAnalytics(ERPNextTestSuite):
"""purchase_analytics reuses the shared Analytics engine; these tests lock its
wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees."""
def setUp(self):
frappe.set_user("Administrator")
def _filters(self, **overrides):
filters = {
"doc_type": "Purchase Order",
"value_quantity": "Value",
"range": "Monthly",
"company": COMPANY,
"from_date": FROM_DATE,
"to_date": TO_DATE,
}
filters.update(overrides)
return frappe._dict(filters)
def _rows(self, filters):
return {row["entity"]: row for row in execute(filters)[1]}
def make_po(self, qty=4, rate=250):
return create_purchase_order(
company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10"
)
def test_supplier_group_tree_rolls_up_to_root(self):
filters = self._filters(tree_type="Supplier Group")
base = self._rows(filters)
base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
po = self.make_po(qty=4, rate=250)
rows = self._rows(filters)
# supplier is remapped to its group; the root sits at indent 0
self.assertIn(SUPPLIER_GROUP, rows)
self.assertIn("All Supplier Groups", rows)
self.assertNotIn(SUPPLIER, rows)
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2)
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total))
def test_item_group_tree_rolls_up_to_root(self):
item_group = frappe.db.get_value("Item", "_Test Item", "item_group")
filters = self._filters(tree_type="Item Group")
base = self._rows(filters)
base_group = flt(base.get(item_group, {}).get("total", 0.0))
po = self.make_po(qty=4, rate=250)
rows = self._rows(filters)
self.assertIn(item_group, rows)
self.assertIn("All Item Groups", rows)
# the raw item code must not leak as its own entity; the root sits at indent 0
self.assertNotIn("_Test Item", rows)
self.assertEqual(rows["All Item Groups"]["indent"], 0)
self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2)
self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total))
def test_supplier_group_by_quantity(self):
filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity")
base = self._rows(filters)
base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0))
po = self.make_po(qty=7, rate=100)
rows = self._rows(filters)
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2)
# the quantity must roll up to the root too, not just the leaf group
self.assertAlmostEqual(
rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2
)

View File

@@ -0,0 +1,49 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.buying.report.subcontract_order_summary.subcontract_order_summary import execute
from erpnext.controllers.tests.test_subcontracting_controller import (
get_subcontracting_order,
make_bom_for_subcontracted_items,
make_raw_materials,
make_service_items,
make_subcontracted_items,
)
from erpnext.tests.utils import ERPNextTestSuite
FG_ITEM = "Subcontracted Item SA7"
class TestSubcontractOrderSummary(ERPNextTestSuite):
"""The report lists Subcontracting Order finished items with their ordered and
received quantities within the transaction-date window."""
def setUp(self):
make_subcontracted_items()
make_raw_materials()
make_service_items()
make_bom_for_subcontracted_items()
def run_report(self, **extra):
filters = frappe._dict(
{"company": "_Test Company", "from_date": add_days(today(), -1), "to_date": add_days(today(), 1)}
)
filters.update(extra)
return execute(filters)[1]
def test_subcontracting_order_is_listed(self):
sco = get_subcontracting_order()
rows = [r for r in self.run_report(name=sco.name) if r.get("item_code") == FG_ITEM]
self.assertTrue(rows, "Subcontracting Order finished item missing from report")
self.assertEqual(rows[0]["qty"], 10)
self.assertEqual(rows[0]["received_qty"], 0) # nothing received yet
def test_out_of_range_date_excludes_order(self):
sco = get_subcontracting_order()
data = self.run_report(name=sco.name, from_date="2019-01-01", to_date="2019-01-31")
self.assertEqual(data, [])

View File

@@ -0,0 +1,66 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.buying.report.supplier_quotation_comparison.supplier_quotation_comparison import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
ITEM = "_Test Item"
class TestSupplierQuotationComparison(ERPNextTestSuite):
"""The report lists Supplier Quotation item lines so quotes for the same item can
be compared across suppliers."""
def make_quotation(self, supplier, qty, rate, uom=None):
item = {"item_code": ITEM, "qty": qty, "rate": rate, "warehouse": "_Test Warehouse - _TC"}
if uom:
item["uom"] = uom
sq = frappe.get_doc(
{
"doctype": "Supplier Quotation",
"supplier": supplier,
"company": COMPANY,
"currency": "INR",
"transaction_date": "2026-06-01",
"items": [item],
}
)
sq.insert()
sq.submit()
return sq
def run_report(self, **extra):
filters = frappe._dict({"company": COMPANY, "from_date": "2026-01-01", "to_date": "2026-12-31"})
filters.update(extra)
return execute(filters)[1]
def test_no_filters_returns_empty(self):
self.assertEqual(execute(None)[1], [])
def test_quotation_line_listed_with_price(self):
# _Test UOM 1 converts at 10 stock units per qty, so price_per_unit
# (amount / stock_qty) diverges from base_rate and the division path is tested
sq = self.make_quotation("_Test Supplier", qty=10, rate=100, uom="_Test UOM 1")
rows = [r for r in self.run_report(item_code=ITEM) if r.get("quotation") == sq.name]
self.assertTrue(rows, "Supplier Quotation line missing from report")
row = rows[0]
self.assertEqual(row["supplier_name"], "_Test Supplier")
self.assertEqual(row["qty"], 10)
self.assertEqual(row["base_rate"], 100)
self.assertEqual(row["base_amount"], 1000)
# 1000 amount / (10 qty * 10 conversion) = 10, distinct from the 100 base_rate
self.assertEqual(row["price_per_unit"], 10)
def test_compares_multiple_suppliers_for_item(self):
sq1 = self.make_quotation("_Test Supplier", qty=10, rate=100)
sq2 = self.make_quotation("_Test Supplier 1", qty=10, rate=120)
quotes = {r["quotation"]: r for r in self.run_report(item_code=ITEM)}
self.assertIn(sq1.name, quotes)
self.assertIn(sq2.name, quotes)
self.assertEqual(quotes[sq1.name]["base_rate"], 100)
self.assertEqual(quotes[sq2.name]["base_rate"], 120)

View File

@@ -341,17 +341,6 @@
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 1,
"label": "Item Wise Consumption",
"link_count": 0,
"link_to": "Item Wise Consumption",
"link_type": "Report",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
@@ -512,9 +501,10 @@
"type": "Link"
}
],
"modified": "2026-01-02 14:55:59.078773",
"modified": "2026-06-14 13:43:50.509039",
"modified_by": "Administrator",
"module": "Buying",
"module_onboarding": "Buying Onboarding",
"name": "Buying",
"number_cards": [
{
@@ -538,6 +528,403 @@
"roles": [],
"sequence_id": 5.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Buying",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Buying",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "notepad-text",
"indent": 0,
"keep_closed": 0,
"label": "Material Request",
"link_to": "Material Request",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "git-pull-request-arrow",
"indent": 0,
"keep_closed": 0,
"label": "Request for Quotation",
"link_to": "Request for Quotation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "book-open-text",
"indent": 0,
"keep_closed": 0,
"label": "Supplier Quotation",
"link_to": "Supplier Quotation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "receipt-text",
"indent": 0,
"keep_closed": 0,
"label": "Purchase Order",
"link_to": "Purchase Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "liabilities",
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice",
"link_to": "Purchase Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Supplier",
"link_to": "Supplier",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Group",
"link_to": "Supplier Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Price List",
"link_to": "Price List",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Address",
"link_to": "Address",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Contacts",
"link_to": "Contact",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Scorecard",
"link_to": "Supplier Scorecard",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Scorecard Criteria",
"link_to": "Supplier Scorecard Criteria",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Scorecard Variable",
"link_to": "Supplier Scorecard Variable",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Scorecard Standing",
"link_to": "Supplier Scorecard Standing",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Analytics",
"link_to": "Purchase Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Order Analysis",
"link_to": "Purchase Order Analysis",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Requested Items to Order and Receive",
"link_to": "Requested Items to Order and Receive",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Items To Be Requested",
"link_to": "Items To Be Requested",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item-wise Purchase History",
"link_to": "Item-wise Purchase History",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Receipt Trends ",
"link_to": "Purchase Receipt Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice Trends",
"link_to": "Purchase Invoice Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Order Trends",
"link_to": "Purchase Order Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Procurement Tracker",
"link_to": "Procurement Tracker",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item Wise Consumption",
"link_to": "Item Wise Consumption",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Quotation Comparison",
"link_to": "Supplier Quotation Comparison",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Addresses And Contacts",
"link_to": "Address And Contacts",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Buying Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Buying",
"type": "Workspace"
}

View File

@@ -47,7 +47,6 @@ from erpnext.controllers.sales_and_purchase_return import validate_return
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.item.item import get_uom_conv_factor
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_item_details,
)
from erpnext.utilities.regional import temporary_flag
@@ -782,7 +781,7 @@ class AccountsController(TransactionBase):
for item in self.get("items"):
if item.get("item_code"):
ctx: ItemDetailsCtx = ItemDetailsCtx(parent_dict.copy())
ctx: frappe._dict = frappe._dict(parent_dict.copy())
ctx.update(item.as_dict())
ctx.update(

View File

@@ -25,7 +25,7 @@ from pypika import Order
import erpnext
from erpnext.accounts.utils import build_qb_match_conditions
from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template
from erpnext.stock.get_item_details import _get_item_tax_template
from erpnext.stock.utils import get_combine_datetime
from erpnext.utilities.query import get_filter_conditions_qb
@@ -1056,7 +1056,7 @@ def get_tax_template(doctype: str, txt: str, searchfield: str, start: int, page_
valid_from = filters.get("valid_from")
valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from
ctx = ItemDetailsCtx(
ctx = frappe._dict(
{
"item_code": filters.get("item_code"),
"posting_date": valid_from,

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