Compare commits

...

280 Commits

Author SHA1 Message Date
Nabin Hait
50b6f50b88 test: assert root rollup and no item leak in Purchase Analytics 2026-07-02 17:16:20 +05:30
Nabin Hait
6e57bd325f test: Purchase Analytics report coverage 2026-07-02 15:45:50 +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
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
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
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
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
sokumon
90aba582ec chore: export workspaces with new schema 2026-06-12 13:08:48 +05:30
196 changed files with 16208 additions and 1866 deletions

View File

@@ -177,6 +177,16 @@ 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.
---

View File

@@ -105,6 +105,11 @@ 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)
@@ -132,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,33 +14,32 @@
"@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",
@@ -52,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

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

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

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

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

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

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

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

@@ -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,
@@ -960,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,
@@ -1140,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,
@@ -1178,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

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

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

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

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

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

View File

@@ -167,7 +167,8 @@ status_map = {
"Pick List": [
["Draft", None],
["Open", "eval:self.docstatus == 1"],
["Completed", "stock_entry_exists"],
["Completed", "is_fully_transferred"],
["Partially Transferred", "is_partially_transferred"],
[
"Partly Delivered",
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",

View File

@@ -21,7 +21,6 @@ from erpnext.controllers.accounts_controller import (
from erpnext.deprecation_dumpster import deprecated
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
ItemDetailsCtx,
_get_item_tax_template,
get_item_tax_map,
)
@@ -99,7 +98,7 @@ class calculate_taxes_and_totals:
for item in self.doc.items:
if item.item_code and item.get("item_tax_template"):
item_doc = frappe.get_cached_doc("Item", item.item_code)
ctx = ItemDetailsCtx(
ctx = frappe._dict(
{
"net_rate": item.net_rate or item.rate,
"base_net_rate": item.base_net_rate or item.base_rate,

View File

@@ -154,15 +154,26 @@ def create_customer(customer_data: dict | None = None):
customer.set(field, customer_data.get(field))
customer.insert(ignore_permissions=True)
customer_name = customer.name
contacts = frappe.parse_json(customer_data.get("contacts"))
create_contacts(contacts, customer_name, "Customer", customer_name)
create_address("Customer", customer_name, customer_data.get("address"))
return customer_name
except Exception:
frappe.db.rollback()
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
pass
return
# Link contacts/address under a savepoint so a failure here does NOT discard the Customer just
# created (a full rollback would; MariaDB kept it pre-migration). Linking is best-effort.
frappe.db.savepoint("crm_customer_links")
try:
contacts = frappe.parse_json(customer_data.get("contacts"))
create_contacts(contacts, customer_name, "Customer", customer_name)
create_address("Customer", customer_name, customer_data.get("address"))
except Exception:
frappe.db.rollback(save_point="crm_customer_links")
frappe.log_error(frappe.get_traceback(), "Error while linking contacts/address to new Customer")
# keep the Customer, but preserve the pre-existing contract of returning None on a linking failure
# so CRM callers still see the failure signal
return
return customer_name
def validate_frappe_crm_sync():

View File

@@ -2,11 +2,11 @@
"app": "erpnext",
"charts": [
{
"chart_name": "Won Opportunities",
"label": "Won Opportunities"
"chart_name": "Territory Wise Sales",
"label": "Territory Wise Sales"
}
],
"content": "[{\"id\":\"4jhDsfZ7EP\",\"type\":\"header\",\"data\":{\"text\":\"This module is scheduled for deprecation and will be completely removed in version 17, please use <a href=\\\"https://frappe.io/crm\\\">Frappe CRM</a> instead.\",\"col\":12}},{\"id\":\"-bzBQ_IbL9\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Won Opportunities\",\"col\":12}},{\"id\":\"LdM1QgUnqU\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"New Lead (Last 1 Month)\",\"col\":4}},{\"id\":\"X23-SXBcYG\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"New Opportunity (Last 1 Month)\",\"col\":4}},{\"id\":\"3rm7fH52M-\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Won Opportunity (Last 1 Month)\",\"col\":4}},{\"id\":\"K6a2Kh5Zav\",\"type\":\"spacer\",\"data\":{\"col\":12}}]",
"content": "[{\"id\":\"4jhDsfZ7EP\",\"type\":\"header\",\"data\":{\"text\":\"This module is scheduled for deprecation and will be completely removed in version 17, please use <a href=\\\"http://frappe.io/crm\\\">Frappe CRM</a> instead.\",\"col\":12}},{\"id\":\"Cj2TyhgiWy\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Territory Wise Sales\",\"col\":12}},{\"id\":\"LAKRmpYMRA\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"69RN0XsiJK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead\",\"col\":3}},{\"id\":\"t6PQ0vY-Iw\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Opportunity\",\"col\":3}},{\"id\":\"VOFE0hqXRD\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"0ik53fuemG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Sales Analytics\",\"col\":3}},{\"id\":\"wdROEmB_XG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"-I9HhcgUKE\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"ttpROKW9vk\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports &amp; Masters</b></span>\",\"col\":12}},{\"id\":\"-76QPdbBHy\",\"type\":\"card\",\"data\":{\"card_name\":\"Sales Pipeline\",\"col\":4}},{\"id\":\"_YmGwzVWRr\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"Bma1PxoXk3\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"80viA0R83a\",\"type\":\"card\",\"data\":{\"card_name\":\"Campaign\",\"col\":4}},{\"id\":\"Buo5HtKRFN\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"sLS_x4FMK2\",\"type\":\"card\",\"data\":{\"card_name\":\"Maintenance\",\"col\":4}}]",
"creation": "2020-01-23 14:48:30.183272",
"custom_blocks": [],
"docstatus": 0,
@@ -18,6 +18,14 @@
"is_hidden": 0,
"label": "CRM",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Reports",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "Lead",
"hidden": 0,
@@ -115,6 +123,14 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Maintenance",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
@@ -148,6 +164,183 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Sales Pipeline",
"link_count": 7,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Lead",
"link_count": 0,
"link_to": "Lead",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Opportunity",
"link_count": 0,
"link_to": "Opportunity",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Customer",
"link_count": 0,
"link_to": "Customer",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Contract",
"link_count": 0,
"link_to": "Contract",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Appointment",
"link_count": 0,
"link_to": "Appointment",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
"link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Communication",
"link_count": 0,
"link_to": "Communication",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Settings",
"link_count": 2,
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "CRM Settings",
"link_count": 0,
"link_to": "CRM Settings",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "SMS Settings",
"link_count": 0,
"link_to": "SMS Settings",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Campaign",
"link_count": 5,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Campaign",
"link_count": 0,
"link_to": "Campaign",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Email Campaign",
"link_count": 0,
"link_to": "Email Campaign",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "SMS Center",
"link_count": 0,
"link_to": "SMS Center",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "SMS Log",
"link_count": 0,
"link_to": "SMS Log",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
"link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
@@ -228,24 +421,11 @@
"type": "Link"
}
],
"modified": "2026-01-03 15:05:23.983099",
"modified": "2026-06-14 13:44:08.297053",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM",
"number_cards": [
{
"label": "New Lead (Last 1 Month)",
"number_card_name": "New Lead (Last 1 Month)"
},
{
"label": "New Opportunity (Last 1 Month)",
"number_card_name": "New Opportunity (Last 1 Month)"
},
{
"label": "Won Opportunity (Last 1 Month)",
"number_card_name": "Won Opportunity (Last 1 Month)"
}
],
"number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
@@ -253,7 +433,552 @@
"restrict_to_domain": "",
"roles": [],
"sequence_id": 17.0,
"shortcuts": [],
"shortcuts": [
{
"color": "Blue",
"format": "{} Open",
"label": "Lead",
"link_to": "Lead",
"stats_filter": "{\"status\":\"Open\"}",
"type": "DocType"
},
{
"color": "Blue",
"format": "{} Assigned",
"label": "Opportunity",
"link_to": "Opportunity",
"stats_filter": "{\"_assign\": [\"like\", '%' + frappe.session.user + '%']}",
"type": "DocType"
},
{
"label": "Customer",
"link_to": "Customer",
"type": "DocType"
},
{
"label": "Sales Analytics",
"link_to": "Sales Analytics",
"report_ref_doctype": "Sales Order",
"type": "Report"
},
{
"label": "Dashboard",
"link_to": "CRM",
"type": "Dashboard"
}
],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "CRM",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "users-round",
"indent": 0,
"keep_closed": 0,
"label": "Lead",
"link_to": "Lead",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "lightbulb",
"indent": 0,
"keep_closed": 0,
"label": "Opportunity",
"link_to": "Opportunity",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "customer",
"indent": 0,
"keep_closed": 0,
"label": "Customer",
"link_to": "Customer",
"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,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Sales Analytics",
"link_to": "Sales Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Lead Details",
"link_to": "Lead Details",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Pipeline Analytics",
"link_to": "Sales Pipeline Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Opportunity Summary by Sales Stage",
"link_to": "Opportunity Summary by Sales Stage",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Funnel",
"link_to": "sales-funnel",
"link_type": "Page",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Prospects Engaged But Not Converted",
"link_to": "Prospects Engaged But Not Converted",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "First Response Time for Opportunity",
"link_to": "First Response Time for Opportunity",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Campaign Efficiency",
"link_to": "Campaign Efficiency",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Lead Owner Efficiency",
"link_to": "Lead Owner Efficiency",
"link_type": "Report",
"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": "Maintenance Schedule",
"link_to": "Maintenance Schedule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Maintenance Visit",
"link_to": "Maintenance Visit",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Warranty Claim",
"link_to": "Warranty Claim",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "funnel",
"indent": 1,
"keep_closed": 1,
"label": "Sales Pipeline",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Lead",
"link_to": "Lead",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Opportunity",
"link_to": "Opportunity",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"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": "Contract",
"link_to": "Contract",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Appointment",
"link_to": "Appointment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Communication",
"link_to": "Communication",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sell",
"indent": 1,
"keep_closed": 1,
"label": "Campaign",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Campaign",
"link_to": "Campaign",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Email Campaign",
"link_to": "Email Campaign",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "SMS Center",
"link_to": "SMS Center",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "SMS Log",
"link_to": "SMS Log",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Email Group",
"link_to": "Email Group",
"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": "Territory",
"link_to": "Territory",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Group",
"link_to": "Customer Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Contact",
"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": "Prospect",
"link_to": "Prospect",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Person",
"link_to": "Sales Person",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Stage",
"link_to": "Sales Stage",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Lead Source",
"link_to": "UTM Source",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 1,
"keep_closed": 1,
"label": "Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "CRM Settings",
"link_to": "CRM Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "SMS Settings",
"link_to": "SMS Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "CRM",
"type": "Workspace"
}

View File

@@ -215,7 +215,14 @@ def sync_transactions(bank, bank_account):
result = []
if transactions:
for transaction in reversed(transactions):
result += new_bank_transaction(transaction)
# per-transaction savepoint: a failed insert/submit must not discard the Bank
# Transactions already synced this run (MariaDB keeps them) nor poison the txn on Postgres
frappe.db.savepoint("plaid_sync_txn")
try:
result += new_bank_transaction(transaction)
except Exception:
frappe.db.rollback(save_point="plaid_sync_txn")
raise
if result:
last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date")

View File

@@ -8,7 +8,7 @@ app_email = "hello@frappe.io"
app_license = "GNU General Public License (v3)"
source_link = "https://github.com/frappe/erpnext"
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
app_home = "/desk"
app_home = "/desk/home"
add_to_apps_screen = [
{
@@ -507,6 +507,7 @@ scheduler_events = {
],
"weekly": [
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
"erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries",
],
"monthly_long": [
"erpnext.accounts.deferred_revenue.process_deferred_accounting",

View File

@@ -16,7 +16,7 @@ from frappe.website.website_generator import WebsiteGenerator
import erpnext
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.item.item import get_item_details
from erpnext.stock.get_item_details import ItemDetailsCtx, get_conversion_factor, get_price_list_rate
from erpnext.stock.get_item_details import get_conversion_factor, get_price_list_rate
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -739,7 +739,7 @@ class BOM(WebsiteGenerator):
)
)
def check_recursion(self, bom_list=None):
def check_recursion(self):
"""Check whether recursion occurs in any bom"""
bom_list = self.traverse_tree()
child_items = frappe.get_all(
@@ -861,21 +861,30 @@ class BOM(WebsiteGenerator):
self.append("items", row)
def traverse_tree(self, bom_list=None):
count = 0
if not bom_list:
bom_list = []
def traverse_tree(self):
"""Return this BOM and every descendant BOM. The whole sub-tree is fetched in one recursive
CTE (frappe.qb) instead of a query-per-node walk; the only caller (check_recursion) uses the
result purely as a membership set. Portable across postgres and mariadb 10.2+."""
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("bom_tree")
if self.name not in bom_list:
bom_list.append(self.name)
seed = (
frappe.qb.from_(bom_item)
.select(bom_item.bom_no.as_("bom"))
.where((bom_item.parent == self.name) & (bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.parent == tree.bom)
.select(bom_item.bom_no)
.where((bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
)
descendants = (
frappe.qb.with_(seed + recursion, "bom_tree", recursive=True).from_(tree).select(tree.bom)
).run(pluck=True)
while count < len(bom_list):
for child_bom in _get_bom_children(bom_list[count]):
if child_bom not in bom_list:
bom_list.append(child_bom)
count += 1
bom_list.reverse()
return bom_list
return [self.name, *descendants]
def company_currency(self):
return erpnext.get_company_currency(self.company)
@@ -1072,7 +1081,7 @@ def _get_price_list_item_rate(args, bom_doc):
if not bom_doc.buying_price_list:
frappe.throw(_("Please select Price List"))
ctx = ItemDetailsCtx(
ctx = frappe._dict(
{
"doctype": "BOM",
"price_list": bom_doc.buying_price_list,

View File

@@ -67,29 +67,33 @@ def update_cost_in_level(doc: "BOMUpdateLog", bom_list: list[str], batch_name: i
frappe.db.commit() # nosemgrep
def get_ancestor_boms(new_bom: str, bom_list: list | None = None) -> list:
"Recursively get all ancestors of BOM."
bom_list = bom_list or []
def get_ancestor_boms(new_bom: str) -> list:
"""Return every ancestor BOM of `new_bom` (BOMs that consume it, transitively) in one recursive
CTE built with frappe.qb -- portable across postgres and mariadb 10.2+. `UNION` makes it
cycle-safe (it stops once no new BOM is reached); a BOM that is its own ancestor is rejected."""
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("ancestor_boms")
parents = (
seed = (
frappe.qb.from_(bom_item)
.select(bom_item.parent)
.select(bom_item.parent.as_("bom"))
.where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
.run(as_dict=True)
)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.bom_no == tree.bom)
.select(bom_item.parent)
.where((bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
)
ancestors = (
frappe.qb.with_(seed + recursion, "ancestor_boms", recursive=True).from_(tree).select(tree.bom)
).run(pluck=True)
for d in parents:
if new_bom == d.parent:
frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
if new_bom in ancestors:
frappe.throw(_("BOM recursion: {0} cannot be an ancestor of itself").format(new_bom))
if d.parent not in tuple(bom_list):
bom_list.append(d.parent)
get_ancestor_boms(d.parent, bom_list)
return bom_list
return ancestors
def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:

View File

@@ -149,6 +149,16 @@ class RequiredItemsService:
self.recompute_material_transferred_for_manufacturing(transferred_items)
def refresh_material_transferred_for_manufacturing(self):
"""Recompute material_transferred_for_manufacturing only, without touching per-row
transferred_qty or stock reservations. Used to get a status decision (Not Started vs
In Process) based on fresh data, ahead of the fuller update_required_items() pass.
"""
if self.doc.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the

View File

@@ -87,6 +87,12 @@ class StatusService:
def update_status(self, status=None):
"""Update status of work order if unknown"""
if self.doc.docstatus == 1:
# Refresh material_transferred_for_manufacturing before deciding status so pick-list-
# driven transfers (where this qty is derived from item transfers, not fg_completed_qty)
# are reflected immediately, instead of only after the next status update call.
self.doc.refresh_material_transferred_for_manufacturing()
if self.doc.status != "Closed":
if status not in ["Stopped", "Closed"]:
status = self.get_status(status)

View File

@@ -1003,6 +1003,9 @@ class WorkOrder(Document):
def update_transferred_qty_for_required_items(self):
return RequiredItemsService(self).update_transferred_qty_for_required_items()
def refresh_material_transferred_for_manufacturing(self):
return RequiredItemsService(self).refresh_material_transferred_for_manufacturing()
def update_returned_qty(self):
return RequiredItemsService(self).update_returned_qty()

View File

@@ -2,6 +2,8 @@
# For license information, please see license.txt
from collections import defaultdict
import frappe
from frappe import _
@@ -14,29 +16,47 @@ def execute(filters=None):
def get_data(filters, data):
get_exploded_items(filters.bom, data)
children_map = fetch_exploded_bom_items(filters.bom)
build_exploded_rows(filters.bom, children_map, data)
def get_exploded_items(bom, data, indent=0, qty=1):
exploded_items = frappe.get_all(
"BOM Item",
filters={"parent": bom},
fields=[
"qty",
"bom_no",
"qty",
"item_code",
"item_name",
"description",
"uom",
"idx",
"is_phantom_item",
],
order_by="idx ASC",
def fetch_exploded_bom_items(root_bom):
"""Every BOM Item in the exploded tree of `root_bom`, grouped by its parent BOM, in one
recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe
and fetches each sub-BOM's items only once even when it is reused across the tree."""
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("exploded_bom")
fields = [
bom_item.parent,
bom_item.qty,
bom_item.bom_no,
bom_item.item_code,
bom_item.item_name,
bom_item.description,
bom_item.uom,
bom_item.idx,
bom_item.is_phantom_item,
]
seed = frappe.qb.from_(bom_item).select(*fields).where(bom_item.parent == root_bom)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.parent == tree.bom_no)
.select(*fields)
.where(tree.bom_no != "")
)
rows = (
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star)
).run(as_dict=True)
for item in exploded_items:
item["indent"] = indent
children_map = defaultdict(list)
for row in rows:
children_map[row.parent].append(row)
return children_map
def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
for item in sorted(children_map.get(bom, []), key=lambda row: row.idx):
data.append(
{
"item_code": item.item_code,
@@ -51,7 +71,7 @@ def get_exploded_items(bom, data, indent=0, qty=1):
}
)
if item.bom_no:
get_exploded_items(item.bom_no, data, indent=indent + 1, qty=item.qty)
build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty)
def get_columns():

View File

@@ -0,0 +1,80 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.manufacturing.report.bom_explorer.bom_explorer import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestBOMExplorer(ERPNextTestSuite):
def setUp(self):
# the tests look up `_Test FG Item`'s BOM, which comes from the BOM fixtures;
# load them so the file also passes when run in isolation
self.load_test_records("BOM")
def run_report(self, bom):
filters = frappe._dict({"bom": bom})
return execute(filters)[1]
def top_level_rows_by_item(self, data):
# key only the direct (indent 0) components, so an item that also appears in a
# deeper sub-assembly can't overwrite the top-level row we assert against
return {row["item_code"]: row for row in data if row["indent"] == 0}
def test_default_bom_lists_components_at_top_level(self):
bom = frappe.db.get_value("BOM", {"item": "_Test FG Item", "is_active": 1, "is_default": 1})
self.assertIsNotNone(bom, "Default active BOM for _Test FG Item not found")
data = self.run_report(bom)
rows_by_item = self.top_level_rows_by_item(data)
self.assertIn("_Test Item", rows_by_item)
self.assertIn("_Test Item Home Desktop 100", rows_by_item)
for item_code in ("_Test Item", "_Test Item Home Desktop 100"):
row = rows_by_item[item_code]
self.assertEqual(row["indent"], 0)
self.assertEqual(row["bom_level"], 0)
def test_qty_matches_bom_item_qty(self):
bom = frappe.db.get_value("BOM", {"item": "_Test FG Item", "is_active": 1, "is_default": 1})
data = self.run_report(bom)
rows_by_item = self.top_level_rows_by_item(data)
for bom_item in frappe.get_all(
"BOM Item", filters={"parent": bom}, fields=["item_code", "qty", "uom"]
):
row = rows_by_item[bom_item.item_code]
self.assertEqual(row["qty"], bom_item.qty)
self.assertEqual(row["uom"], bom_item.uom)
def test_nested_bom_shows_deeper_level(self):
# Sub-assembly: "sub" is itself a BOM containing "leaf".
parent_bom = create_nested_bom(
{"parent": {"sub": {"leaf": {}}, "flat": {}}},
prefix="_Test explorer ",
)
data = self.run_report(parent_bom.name)
rows_by_item = {row["item_code"]: row for row in data}
sub_item = "_Test explorer sub"
leaf_item = "_Test explorer leaf"
flat_item = "_Test explorer flat"
self.assertIn(sub_item, rows_by_item)
self.assertIn(flat_item, rows_by_item)
self.assertIn(leaf_item, rows_by_item)
# Direct components of the parent sit at level 0.
self.assertEqual(rows_by_item[flat_item]["indent"], 0)
self.assertEqual(rows_by_item[sub_item]["indent"], 0)
# The sub-assembly row carries its own BOM reference.
self.assertTrue(rows_by_item[sub_item]["bom"])
# The leaf belongs to the sub-assembly, so it is exploded one level deeper.
self.assertEqual(rows_by_item[leaf_item]["indent"], 1)
self.assertEqual(rows_by_item[leaf_item]["bom_level"], 1)

View File

@@ -0,0 +1,117 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.report.bom_operations_time.bom_operations_time import execute
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
OPERATION = "_Test BOM Ops Time Operation"
WORKSTATION = "_Test BOM Ops Time Workstation"
OTHER_OPERATION = "_Test BOM Ops Time Operation 2"
OTHER_WORKSTATION = "_Test BOM Ops Time Workstation 2"
TIME_IN_MINS = 45
class TestBOMOperationsTime(ERPNextTestSuite):
def setUp(self):
ensure_workstation_and_operation(WORKSTATION, OPERATION)
self.rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
self.fg_item = make_item(properties={"is_stock_item": 1}).name
self.bom = build_bom_with_operation(self.fg_item, self.rm_item, OPERATION, WORKSTATION)
def run_report(self, **filters):
return execute(frappe._dict(filters))[1]
def bom_names(self, rows):
return {row.name for row in rows}
def build_other_bom(self):
"""A submitted BOM for a different item, built on a different workstation."""
ensure_workstation_and_operation(OTHER_WORKSTATION, OTHER_OPERATION)
other_fg = make_item(properties={"is_stock_item": 1}).name
return build_bom_with_operation(other_fg, self.rm_item, OTHER_OPERATION, OTHER_WORKSTATION)
def test_operation_row_appears_with_expected_values(self):
rows = self.run_report(bom_id=[self.bom.name])
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row.name, self.bom.name)
self.assertEqual(row.item, self.fg_item)
self.assertEqual(row.operation, OPERATION)
self.assertEqual(row.workstation, WORKSTATION)
self.assertEqual(row.time_in_mins, TIME_IN_MINS)
def test_item_code_filter_includes_matching_and_excludes_other(self):
other_bom = self.build_other_bom()
# no bom_id here, so the item_code filter alone must scope the result
names = self.bom_names(self.run_report(item_code=self.fg_item))
self.assertIn(self.bom.name, names)
self.assertNotIn(other_bom.name, names)
# reverse direction: filtering the other item drops our BOM
other_names = self.bom_names(self.run_report(item_code=other_bom.item))
self.assertIn(other_bom.name, other_names)
self.assertNotIn(self.bom.name, other_names)
def test_workstation_filter_includes_matching_and_excludes_other(self):
other_bom = self.build_other_bom()
# no bom_id here, so the workstation filter alone must scope the result
names = self.bom_names(self.run_report(workstation=WORKSTATION))
self.assertIn(self.bom.name, names)
self.assertNotIn(other_bom.name, names)
# reverse direction: filtering the other workstation drops our BOM
other_names = self.bom_names(self.run_report(workstation=OTHER_WORKSTATION))
self.assertIn(other_bom.name, other_names)
self.assertNotIn(self.bom.name, other_names)
def test_draft_bom_excluded(self):
draft_bom = build_bom_with_operation(
make_item(properties={"is_stock_item": 1}).name,
self.rm_item,
OPERATION,
WORKSTATION,
do_not_submit=True,
)
rows = self.run_report(bom_id=[draft_bom.name])
self.assertEqual(rows, [])
def ensure_workstation_and_operation(workstation, operation):
if not frappe.db.exists("Workstation", workstation):
frappe.get_doc({"doctype": "Workstation", "workstation_name": workstation}).insert(
ignore_permissions=True
)
if not frappe.db.exists("Operation", operation):
frappe.get_doc({"doctype": "Operation", "name": operation, "workstation": workstation}).insert(
ignore_permissions=True
)
def build_bom_with_operation(fg_item, rm_item, operation, workstation, do_not_submit=False):
bom = make_bom(
item=fg_item,
raw_materials=[rm_item],
with_operations=1,
do_not_save=True,
)
bom.append(
"operations",
{
"operation": operation,
"workstation": workstation,
"time_in_mins": TIME_IN_MINS,
"hour_rate": 100,
},
)
bom.insert(ignore_permissions=True)
if not do_not_submit:
bom.submit()
return bom

View File

@@ -0,0 +1,110 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.bom_variance_report.bom_variance_report import execute
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestBOMVarianceReport(ERPNextTestSuite):
def setUp(self):
self.production_item = "_Test FG Item"
self.warehouse = "_Test Warehouse - _TC"
self.bom_no = frappe.db.get_value(
"BOM", {"item": self.production_item, "is_active": 1, "is_default": 1}
)
self.raw_materials = self.get_bom_raw_materials()
# allow over-production so a Work Order can produce more than planned; ERPNextTestSuite
# rolls this back at tearDown, so no manual restore is needed
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 100)
def get_bom_raw_materials(self):
return {
row.item_code: row.qty
for row in frappe.get_all(
"BOM Item", filters={"parent": self.bom_no}, fields=["item_code", "qty"]
)
}
def create_over_produced_work_order(self, ordered_qty=2, produced_qty=3):
work_order = make_wo_order_test_record(
item=self.production_item,
qty=ordered_qty,
source_warehouse=self.warehouse,
skip_transfer=1,
)
for item_code in self.raw_materials:
test_stock_entry.make_stock_entry(
item_code=item_code, target=self.warehouse, qty=100, basic_rate=100
)
stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", produced_qty))
stock_entry.submit()
work_order.reload()
self.assertEqual(work_order.produced_qty, produced_qty)
return work_order
def run_report(self, **extra):
filters = frappe._dict({"bom_no": self.bom_no, **extra})
return execute(filters)[1]
def test_over_produced_work_order_appears_with_planned_and_actual(self):
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=3)
data = self.run_report(work_order=work_order.name)
summary_rows = [row for row in data if row.get("work_order") == work_order.name]
self.assertEqual(len(summary_rows), 1)
summary = summary_rows[0]
self.assertEqual(summary.get("production_item"), self.production_item)
self.assertEqual(summary.get("bom_no"), self.bom_no)
self.assertEqual(summary.get("qty"), 2)
self.assertEqual(summary.get("produced_qty"), 3)
raw_material_rows = {
row.get("raw_material_code"): row for row in data if row.get("raw_material_code")
}
for item_code, per_unit_qty in self.raw_materials.items():
self.assertIn(item_code, raw_material_rows)
# planned/required qty scales with the ordered qty on the work order
self.assertEqual(raw_material_rows[item_code].get("required_qty"), per_unit_qty * 2)
def test_bom_no_filter_returns_over_produced_orders(self):
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=3)
data = self.run_report()
matched = [row for row in data if row.get("work_order") == work_order.name]
self.assertEqual(len(matched), 1)
self.assertEqual(matched[0].get("bom_no"), self.bom_no)
def test_unstarted_work_order_is_excluded(self):
work_order = make_wo_order_test_record(
item=self.production_item,
qty=2,
source_warehouse=self.warehouse,
skip_transfer=1,
)
data = self.run_report(work_order=work_order.name)
matched = [row for row in data if row.get("work_order") == work_order.name]
self.assertEqual(matched, [])
def test_work_order_produced_exactly_on_plan_is_excluded(self):
# the canonical no-variance case: produced qty equals the planned qty, so the
# report (which lists only over-produced orders) must not include it
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=2)
data = self.run_report(work_order=work_order.name)
matched = [row for row in data if row.get("work_order") == work_order.name]
self.assertEqual(matched, [])

View File

@@ -120,6 +120,12 @@ def get_columns(filters):
"options": "Workstation",
"width": "100",
},
{
"label": _("Hour Rate"),
"fieldtype": "Currency",
"fieldname": "hour_rate",
"width": "120",
},
{
"label": _("Operating Cost"),
"fieldtype": "Currency",

View File

@@ -0,0 +1,130 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils.data import add_to_date, now
from erpnext.manufacturing.doctype.job_card.mapper import make_corrective_job_card
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.cost_of_poor_quality_report.cost_of_poor_quality_report import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCostOfPoorQualityReport(ERPNextTestSuite):
"""A Job Card appears in this report only when it is submitted (docstatus == 1) and flagged
as a corrective job card (is_corrective_job_card == 1). Such a card is created against a
corrective Operation (is_corrective_operation == 1); without any corrective operation the
report returns no rows at all."""
def setUp(self):
self.load_test_records("BOM")
def create_corrective_job_card(self, hour_rate=100):
"""Produce a submitted corrective Job Card and return (corrective_jc, operation, workstation)."""
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
job_card = frappe.get_last_doc("Job Card", {"work_order": work_order.name})
job_card.append(
"time_logs",
{"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2},
)
job_card.submit()
corrective_operation = frappe.get_doc(
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
).insert()
corrective_job_card = make_corrective_job_card(
job_card.name, operation=corrective_operation.name, for_operation=job_card.operation
)
corrective_job_card.hour_rate = hour_rate
corrective_job_card.insert()
corrective_job_card.append(
"time_logs",
{
"from_time": add_to_date(now(), hours=2),
"to_time": add_to_date(now(), hours=2, minutes=30),
"completed_qty": 2,
},
)
corrective_job_card.submit()
return corrective_job_card, corrective_operation.name, corrective_job_card.workstation
def run_report(self, **filters):
return execute(frappe._dict(filters))[1]
def test_corrective_job_card_is_listed_with_expected_fields(self):
corrective_jc, operation, workstation = self.create_corrective_job_card(hour_rate=100)
rows = self.run_report(company="_Test Company")
row = next((r for r in rows if r["name"] == corrective_jc.name), None)
self.assertIsNotNone(row, "Submitted corrective job card must appear in the report")
self.assertEqual(row["work_order"], corrective_jc.work_order)
self.assertEqual(row["operation"], operation)
self.assertEqual(row["workstation"], workstation)
self.assertEqual(row["item_code"], corrective_jc.production_item)
self.assertEqual(row["hour_rate"], 100)
self.assertEqual(row["total_time_in_mins"], corrective_jc.total_time_in_mins)
# operating_cost = hour_rate * total_time_in_mins / 60 (SQL float -> compare approximately)
self.assertAlmostEqual(row["operating_cost"], 100 * corrective_jc.total_time_in_mins / 60.0, places=6)
def test_non_corrective_job_card_is_excluded(self):
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
# The regular (non-corrective) job card the corrective one was raised against must not appear.
regular_jc = corrective_jc.for_job_card
rows = self.run_report(company="_Test Company")
self.assertNotIn(regular_jc, {r["name"] for r in rows})
def test_operation_filter_scopes_rows(self):
corrective_jc, operation, _workstation = self.create_corrective_job_card()
matching = self.run_report(company="_Test Company", operation=operation)
self.assertIn(corrective_jc.name, {r["name"] for r in matching})
other_operation = frappe.get_doc(
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
).insert()
filtered = self.run_report(company="_Test Company", operation=other_operation.name)
self.assertNotIn(corrective_jc.name, {r["name"] for r in filtered})
def test_workstation_filter_scopes_rows(self):
corrective_jc, _operation, workstation = self.create_corrective_job_card()
matching = self.run_report(company="_Test Company", workstation=workstation)
self.assertIn(corrective_jc.name, {r["name"] for r in matching})
filtered = self.run_report(company="_Test Company", workstation="__non_existent_ws__")
self.assertNotIn(corrective_jc.name, {r["name"] for r in filtered})
def test_work_order_and_name_filters_scope_rows(self):
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
by_work_order = self.run_report(company="_Test Company", work_order=corrective_jc.work_order)
self.assertIn(corrective_jc.name, {r["name"] for r in by_work_order})
by_name = self.run_report(company="_Test Company", name=corrective_jc.name)
self.assertEqual({r["name"] for r in by_name}, {corrective_jc.name})
def test_date_filter_scopes_rows(self):
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
# Time logs sit ~2 hours from now; a window covering today includes the card.
within = self.run_report(
company="_Test Company",
work_order=corrective_jc.work_order,
from_date=add_to_date(now(), days=-1),
to_date=add_to_date(now(), days=1),
)
self.assertIn(corrective_jc.name, {r["name"] for r in within})
# A future-only window excludes it, proving the Job Card Time Log join filters by time.
outside = self.run_report(
company="_Test Company",
work_order=corrective_jc.work_order,
from_date=add_to_date(now(), days=5),
to_date=add_to_date(now(), days=6),
)
self.assertNotIn(corrective_jc.name, {r["name"] for r in outside})

View File

@@ -0,0 +1,92 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, get_datetime, today
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.manufacturing.report.downtime_analysis.downtime_analysis import execute
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
class TestDowntimeAnalysis(ERPNextTestSuite):
def setUp(self):
self.workstation = make_workstation(workstation="_Test Downtime Workstation").name
self.other_workstation = make_workstation(workstation="_Test Downtime Workstation 2").name
self.operator = make_employee("test_downtime_operator@example.com", company="_Test Company")
# from_time / to_time are two hours apart -> downtime of 120 minutes (2 hours).
self.from_time = get_datetime(f"{today()} 09:00:00")
self.to_time = get_datetime(f"{today()} 11:00:00")
self.entry = self.make_downtime_entry(self.workstation)
def make_downtime_entry(self, workstation, **extra):
values = {
"doctype": "Downtime Entry",
"workstation": workstation,
"operator": self.operator,
"from_time": self.from_time,
"to_time": self.to_time,
"stop_reason": "Machine malfunction",
}
values.update(extra)
return frappe.get_doc(values).insert()
def run_report(self, **extra):
filters = frappe._dict(
{
"from_date": add_days(today(), -1),
"to_date": add_days(today(), 1),
}
)
filters.update(extra)
return execute(filters)[1]
def row_for_entry(self, rows, name):
return next((row for row in rows if row.get("name") == name), None)
def test_downtime_is_computed_in_hours(self):
# validate() stores downtime in minutes; the report converts it to hours.
self.assertEqual(self.entry.downtime, 120)
row = self.row_for_entry(self.run_report(), self.entry.name)
self.assertIsNotNone(row, "Downtime Entry not present in report output")
self.assertEqual(row.get("workstation"), self.workstation)
self.assertEqual(row.get("operator"), self.operator)
self.assertEqual(row.get("stop_reason"), "Machine malfunction")
self.assertEqual(row.get("downtime"), 2.0)
def test_workstation_filter_scopes_rows(self):
other = self.make_downtime_entry(self.other_workstation)
rows = self.run_report(workstation=self.workstation)
names = {row.get("name") for row in rows}
self.assertIn(self.entry.name, names)
self.assertNotIn(other.name, names)
self.assertTrue(all(row.get("workstation") == self.workstation for row in rows))
def test_date_range_excludes_out_of_window_entries(self):
# The report filters from_time >= from_date and to_time <= to_date; a window
# ending before the entry's from_time must exclude it.
rows = self.run_report(from_date=add_days(today(), -10), to_date=add_days(today(), -5))
self.assertIsNone(self.row_for_entry(rows, self.entry.name))
def test_chart_aggregates_downtime_per_workstation(self):
self.make_downtime_entry(self.workstation)
chart = execute(
frappe._dict(
{
"from_date": add_days(today(), -1),
"to_date": add_days(today(), 1),
"workstation": self.workstation,
}
)
)[3]
self.assertIn(self.workstation, chart["data"]["labels"])
index = chart["data"]["labels"].index(self.workstation)
# Two entries of 2 hours each for this workstation -> 4 hours aggregated.
self.assertEqual(chart["data"]["datasets"][0]["values"][index], 4.0)

View File

@@ -0,0 +1,101 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.manufacturing.report.exponential_smoothing_forecasting.exponential_smoothing_forecasting import (
execute,
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
FROM_DATE = "2026-06-01"
TO_DATE = "2026-08-31"
SMOOTHING_CONSTANT = 0.5
class TestExponentialSmoothingForecasting(ERPNextTestSuite):
"""Drive real submitted Sales Orders and assert the report buckets the ordered
quantities into the correct historical periods and produces a forecast."""
def setUp(self):
# The forecast query has no lower date bound, so it would pick up any committed
# Sales Order for the item. A uniquely-named item keeps the buckets scoped to
# just this test's orders.
self.item = make_item(properties={"is_stock_item": 1}).name
def test_monthly_qty_forecast_from_sales_orders(self):
# Historical demand: distinct calendar months strictly before FROM_DATE.
# Monthly period keys are derived from the period's last day (e.g. "mar_2026").
history = {"mar_2026": 7, "apr_2026": 4, "may_2026": 9}
self.create_sales_orders(
{
"2026-03-15": history["mar_2026"],
"2026-04-15": history["apr_2026"],
"2026-05-15": history["may_2026"],
}
)
columns, row = self.run_report()
fields = {col["fieldname"] for col in columns}
# For Monthly periodicity only future periods are exposed as columns, each as a
# forecast_ field. Historical demand lives in the row data (keyed by month) but is
# not surfaced as its own column.
self.assertIn("forecast_jun_2026", fields, "expected future forecast column")
self.assertNotIn("jun_2026", fields, "future period must not expose raw demand column")
self.assertNotIn("mar_2026", fields, "historical month is not a Monthly report column")
# Historical buckets must exactly reflect the ordered quantities.
for key, qty in history.items():
self.assertEqual(flt(row.get(key)), flt(qty), f"bucket {key} mismatch")
# The forecast seeds at the average of the non-zero historical months and then
# smooths through them in order: F = F + a*(actual - F). Asserting the exact
# analytical value pins the smoothing formula (Jun 2026 works out to ~7.2083).
expected_avg = sum(history.values()) / len(history)
self.assertAlmostEqual(flt(row.get("avg")), expected_avg, places=6)
forecast = expected_avg
for month in ("mar_2026", "apr_2026", "may_2026"):
forecast = forecast + SMOOTHING_CONSTANT * (history[month] - forecast)
self.assertAlmostEqual(flt(row.get("forecast_jun_2026")), forecast, places=6)
def test_ignores_documents_outside_range_and_other_docstatus(self):
self.create_sales_orders({"2026-05-10": 6})
# A draft SO and a future-dated SO must not contribute to historical demand.
make_sales_order(item_code=self.item, qty=100, transaction_date="2026-05-20", do_not_submit=True)
make_sales_order(item_code=self.item, qty=100, transaction_date=FROM_DATE)
_columns, row = self.run_report()
self.assertEqual(flt(row.get("may_2026")), 6.0)
def create_sales_orders(self, date_to_qty):
for transaction_date, qty in date_to_qty.items():
make_sales_order(item_code=self.item, qty=qty, transaction_date=transaction_date)
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"based_on_document": "Sales Order",
"based_on_field": "Qty",
"no_of_years": 3,
"periodicity": "Monthly",
"from_date": FROM_DATE,
"to_date": TO_DATE,
"smoothing_constant": SMOOTHING_CONSTANT,
"item_code": self.item,
}
)
filters.update(extra)
columns, data = execute(filters)[:2]
item_row = next(
(r for r in data if r.get("item_code") == self.item),
None,
)
self.assertIsNotNone(item_row, f"{self.item} row missing from report output")
return columns, item_row

View File

@@ -0,0 +1,87 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.job_card_summary.job_card_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestJobCardSummary(ERPNextTestSuite):
def setUp(self):
# `_Test FG Item 2` has a default active BOM with operations, so submitting a
# Work Order for it auto-creates Job Cards (one per operation).
self.work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
self.job_cards = frappe.get_all(
"Job Card",
filters={"work_order": self.work_order.name},
fields=["name", "operation", "workstation", "production_item", "status"],
)
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 rows_for_work_order(self, rows):
return [row for row in rows if row.get("work_order") == self.work_order.name]
def test_job_cards_are_listed(self):
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
rows = self.rows_for_work_order(self.run_report())
self.assertEqual(len(rows), len(self.job_cards))
reported_names = {row.get("name") for row in rows}
self.assertEqual(reported_names, {jc.name for jc in self.job_cards})
# Fresh (unsubmitted) job cards are reported as Open, and each row carries the
# operation / workstation / production item pulled from the Job Card.
for jc in self.job_cards:
row = next(row for row in rows if row.get("name") == jc.name)
self.assertEqual(row.get("status"), "Open")
self.assertEqual(row.get("operation"), jc.operation)
self.assertEqual(row.get("workstation"), jc.workstation)
self.assertEqual(row.get("production_item"), jc.production_item)
def test_operation_filter_scopes_rows(self):
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
operation = self.job_cards[0].operation
matching = {jc.name for jc in self.job_cards if jc.operation == operation}
rows = self.rows_for_work_order(self.run_report(operation=operation))
self.assertEqual({row.get("name") for row in rows}, matching)
def test_status_filter(self):
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
# The status filter matches the Job Card's *stored* status, so derive the
# expected set from that rather than assuming fresh cards are literally "Open".
stored_status = self.job_cards[0].status
expected = {jc.name for jc in self.job_cards if jc.status == stored_status}
rows = self.rows_for_work_order(self.run_report(status=stored_status))
self.assertEqual({row.get("name") for row in rows}, expected)
# any non-completed card is displayed as "Open" regardless of its stored status
for row in rows:
self.assertEqual(row.get("status"), "Open")
# None of the freshly created job cards are Completed yet.
completed_rows = self.rows_for_work_order(self.run_report(status="Completed"))
self.assertEqual(completed_rows, [])
def test_date_filter_excludes_out_of_range(self):
# Job Card posting_date defaults to today; a past-only window should exclude them.
rows = self.rows_for_work_order(
self.run_report(from_date=add_days(today(), -10), to_date=add_days(today(), -5))
)
self.assertEqual(rows, [])

View File

@@ -50,11 +50,11 @@ def get_data(filters: Filters) -> Data:
.groupby(se.work_order)
)
if "item" in filters:
query.where(wo.production_item == filters.item)
if filters.get("item"):
query = query.where(wo.production_item == filters.item)
if "work_order" in filters:
query.where(wo.name == filters.work_order)
if filters.get("work_order"):
query = query.where(wo.name == filters.work_order)
data = query.run(as_dict=True)

View File

@@ -0,0 +1,114 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import nowdate
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.process_loss_report.process_loss_report import execute
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessLossReport(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": nowdate(),
"to_date": nowdate(),
}
)
filters.update(extra)
return execute(filters)[1]
def find_row(self, data, work_order):
for row in data:
if row.get("name") == work_order:
return row
return None
def make_manufactured_work_order(self, planned_qty, produced_qty):
"""Create a submitted WO and manufacture `produced_qty` of `planned_qty`.
The difference is booked as process loss on the Manufacture stock entry,
which propagates to the work order's `process_loss_qty`.
"""
wo_order = make_wo_order_test_record(production_item="_Test FG Item", qty=planned_qty)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=100
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=100
)
transfer = frappe.get_doc(
make_stock_entry(wo_order.name, "Material Transfer for Manufacture", planned_qty)
)
for d in transfer.get("items"):
d.s_warehouse = "Stores - _TC"
transfer.insert()
transfer.submit()
manufacture = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", planned_qty))
# Reduce the finished good qty below fg_completed_qty so the difference is
# recorded as process loss.
process_loss_qty = planned_qty - produced_qty
if process_loss_qty:
for d in manufacture.get("items"):
if d.is_finished_item:
d.qty = produced_qty
d.transfer_qty = produced_qty * (d.conversion_factor or 1)
manufacture.insert()
manufacture.submit()
wo_order.reload()
return wo_order
def test_work_order_with_process_loss_is_listed(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
self.assertEqual(wo_order.process_loss_qty, 1)
self.assertEqual(wo_order.produced_qty, 4)
data = self.run_report()
row = self.find_row(data, wo_order.name)
self.assertIsNotNone(row, "Work order with process loss should appear in the report")
self.assertEqual(row.production_item, "_Test FG Item")
self.assertEqual(row.qty_to_manufacture, 5)
self.assertEqual(row.produced_qty, 4)
self.assertEqual(row.process_loss_qty, 1)
# total_pl_value = process_loss_qty * (total_fg_value / qty_to_manufacture)
expected_pl_value = row.process_loss_qty * (row.total_fg_value / row.qty_to_manufacture)
self.assertAlmostEqual(row.total_pl_value, expected_pl_value)
self.assertGreater(row.total_pl_value, 0)
def test_work_order_without_process_loss_is_not_listed(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=5)
self.assertEqual(wo_order.process_loss_qty, 0)
self.assertEqual(wo_order.produced_qty, 5)
data = self.run_report()
self.assertIsNone(
self.find_row(data, wo_order.name),
"Work order that produced the full planned qty should not appear (no loss)",
)
def test_item_filter_scopes_rows(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
# a matching production item includes the row, a non-matching one excludes it
self.assertIsNotNone(self.find_row(self.run_report(item="_Test FG Item"), wo_order.name))
self.assertIsNone(self.find_row(self.run_report(item="_Test FG Item 2"), wo_order.name))
def test_work_order_filter_scopes_rows(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
# the matching work order is included, a different work order name is excluded
self.assertIsNotNone(self.find_row(self.run_report(work_order=wo_order.name), wo_order.name))
self.assertIsNone(self.find_row(self.run_report(work_order=f"{wo_order.name}-XX"), wo_order.name))

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _, scrub
from frappe.utils import getdate, today
from frappe.utils import get_datetime, getdate, today
from erpnext.stock.report.stock_analytics.stock_analytics import (
get_period,
@@ -31,7 +31,9 @@ def get_columns(period_columns):
def get_work_orders(filters):
from_date = filters.get("from_date")
to_date = filters.get("to_date")
# `creation` and `actual_end_date` are datetime columns, so a bare date upper
# bound would coerce to midnight and drop records created later on the last day.
to_date = get_datetime(filters.get("to_date")).replace(hour=23, minute=59, second=59)
WorkOrder = frappe.qb.DocType("Work Order")

View File

@@ -0,0 +1,66 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import _
from frappe.utils import get_first_day, get_last_day, today
from erpnext.tests.utils import ERPNextTestSuite
class TestProductionAnalytics(ERPNextTestSuite):
def run_report(self, **extra):
from erpnext.manufacturing.report.production_analytics.production_analytics import execute
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": get_first_day(today()),
"to_date": get_last_day(today()),
"range": "Monthly",
}
)
filters.update(extra)
columns, data, _msg, _chart = execute(filters)
return columns, data
def get_period_count(self, columns, data, status, period_label):
"""Return the count for a status row under the period column resolved by label."""
period_fieldname = next(col["fieldname"] for col in columns if col.get("label") == period_label)
# the report stores the translated status label, so translate before matching
row = next(row for row in data if row["status"] == _(status))
return row[period_fieldname]
def test_submitted_work_order_increments_status_count(self):
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
# pin the reporting window once so both runs use the same period even if the
# test happens to straddle a month boundary
from_date, to_date = get_first_day(today()), get_last_day(today())
# The current month is the period a newly created Work Order falls into (bucketed by creation date).
cols_before, data_before = self.run_report(from_date=from_date, to_date=to_date)
period_label = cols_before[-1]["label"]
before = self.get_period_count(cols_before, data_before, "Not Started", period_label)
wo = make_wo_order_test_record(production_item="_Test FG Item", qty=10, company="_Test Company")
self.assertEqual(wo.docstatus, 1)
# A freshly submitted Work Order with no material transfer has status "Not Started".
self.assertEqual(wo.status, "Not Started")
cols_after, data_after = self.run_report(from_date=from_date, to_date=to_date)
after = self.get_period_count(cols_after, data_after, "Not Started", period_label)
self.assertEqual(after, before + 1)
def test_report_shape(self):
columns, data = self.run_report()
# First column is the Status column, followed by one column per period.
self.assertEqual(columns[0]["fieldname"], "status")
self.assertGreaterEqual(len(columns), 2)
# One row per known Work Order status.
statuses = {row["status"] for row in data}
for status in ("Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"):
self.assertIn(_(status), statuses)

View File

@@ -42,7 +42,9 @@ def get_production_plan_item_details(filters, data, order_details):
order_qty = row.planned_qty
total_produced_qty = 0.0
pending_qty = 0.0
# default to the full planned qty so a plan without any work order still
# reports everything as pending rather than a misleading zero
pending_qty = flt(order_qty)
for work_order in work_orders:
produced_qty = flt(order_details.get((work_order, row.item_code), {}).get("produced_qty", 0))
pending_qty = flt(order_qty) - produced_qty

View File

@@ -0,0 +1,130 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.production_plan.test_production_plan import create_production_plan
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as make_se_from_wo
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.production_plan_summary.production_plan_summary import execute
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestProductionPlanSummary(ERPNextTestSuite):
def run_report(self, production_plan):
filters = frappe._dict({"production_plan": production_plan})
return execute(filters)[1]
def make_plan(self, planned_qty=2):
return create_production_plan(
item_code="_Test FG Item",
planned_qty=planned_qty,
skip_getting_mr_items=1,
)
def make_submitted_work_order(self, plan, qty):
wo = make_wo_order_test_record(
item_code="_Test FG Item",
qty=qty,
company=plan.company,
wip_warehouse="Work In Progress - _TC",
fg_warehouse="Finished Goods - _TC",
skip_transfer=1,
use_multi_level_bom=1,
do_not_submit=True,
)
wo.production_plan = plan.name
wo.production_plan_item = plan.po_items[0].name
wo.submit()
return wo
def stock_required_materials(self, wo):
# make sure every raw material is available in its source warehouse before manufacturing,
# otherwise a clean database raises NegativeStockError
for item in wo.required_items:
make_stock_entry(
item_code=item.item_code,
to_warehouse=item.source_warehouse or "_Test Warehouse - _TC",
qty=item.required_qty + 10,
rate=100,
)
def get_work_order_row(self, data, item_code):
for row in data:
if row.get("item_code") == item_code and row.get("document_type") == "Work Order":
return row
return None
def get_summary_row(self, data, item_code):
for row in data:
if row.get("item_code") == item_code and not row.get("document_type"):
return row
return None
def test_summary_without_work_order(self):
"""A submitted plan with no work order still yields a summary row for the planned item."""
plan = self.make_plan(planned_qty=2)
data = self.run_report(plan.name)
summary = self.get_summary_row(data, "_Test FG Item")
self.assertIsNotNone(summary)
self.assertEqual(summary.get("qty"), 2)
self.assertEqual(summary.get("produced_qty"), 0)
# nothing produced yet, so the whole planned qty is pending
self.assertEqual(summary.get("pending_qty"), 2)
self.assertIsNone(self.get_work_order_row(data, "_Test FG Item"))
def test_summary_with_pending_work_order(self):
"""An unproduced work order shows full planned qty as pending."""
plan = self.make_plan(planned_qty=2)
wo = self.make_submitted_work_order(plan, qty=2)
data = self.run_report(plan.name)
wo_row = self.get_work_order_row(data, "_Test FG Item")
self.assertIsNotNone(wo_row)
self.assertEqual(wo_row.get("document_name"), wo.name)
self.assertEqual(wo_row.get("qty"), 2)
self.assertEqual(wo_row.get("produced_qty"), 0)
self.assertEqual(wo_row.get("pending_qty"), 2)
summary = self.get_summary_row(data, "_Test FG Item")
self.assertEqual(summary.get("qty"), 2)
self.assertEqual(summary.get("produced_qty"), 0)
def test_summary_reflects_produced_qty(self):
"""Producing part of the work order updates produced and pending quantities."""
plan = self.make_plan(planned_qty=2)
wo = self.make_submitted_work_order(plan, qty=2)
self.stock_required_materials(wo)
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
se.submit()
data = self.run_report(plan.name)
wo_row = self.get_work_order_row(data, "_Test FG Item")
self.assertEqual(wo_row.get("document_name"), wo.name)
self.assertEqual(wo_row.get("produced_qty"), 1)
self.assertEqual(wo_row.get("pending_qty"), 1)
summary = self.get_summary_row(data, "_Test FG Item")
self.assertEqual(summary.get("qty"), 2)
self.assertEqual(summary.get("produced_qty"), 1)
self.assertEqual(summary.get("pending_qty"), 1)
def test_summary_scoped_to_its_own_plan(self):
"""Each plan's report only reports its own work order documents."""
plan_a = self.make_plan(planned_qty=2)
wo_a = self.make_submitted_work_order(plan_a, qty=2)
plan_b = self.make_plan(planned_qty=3)
wo_b = self.make_submitted_work_order(plan_b, qty=3)
data_a = self.run_report(plan_a.name)
document_names = {row.get("document_name") for row in data_a if row.get("document_name")}
self.assertIn(wo_a.name, document_names)
self.assertNotIn(wo_b.name, document_names)

View File

@@ -0,0 +1,81 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, nowdate
from erpnext.manufacturing.report.quality_inspection_summary.quality_inspection_summary import execute
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
create_quality_inspection,
make_minimal_job_card,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestQualityInspectionSummary(ERPNextTestSuite):
def setUp(self):
super().setUp()
create_item("_Test Item")
self.job_card = make_minimal_job_card(production_item="_Test Item")
self.qi = create_quality_inspection(
item_code="_Test Item",
reference_type="Job Card",
reference_name=self.job_card,
status="Accepted",
)
def run_report(self, **extra):
filters = frappe._dict(extra)
return execute(filters)[1]
def _rows_for_qi(self, data):
return [row for row in data if row.get("name") == self.qi.name]
def test_appears_in_date_range(self):
data = self.run_report(from_date=add_days(nowdate(), -1), to_date=add_days(nowdate(), 1))
rows = self._rows_for_qi(data)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row["status"], "Accepted")
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["reference_type"], "Job Card")
self.assertEqual(row["reference_name"], self.job_card)
def test_excluded_outside_date_range(self):
data = self.run_report(from_date=add_days(nowdate(), -10), to_date=add_days(nowdate(), -5))
self.assertEqual(self._rows_for_qi(data), [])
def test_status_filter_includes_matching(self):
data = self.run_report(
from_date=add_days(nowdate(), -1),
to_date=add_days(nowdate(), 1),
status=["Accepted"],
)
self.assertEqual(len(self._rows_for_qi(data)), 1)
def test_status_filter_excludes_non_matching(self):
data = self.run_report(
from_date=add_days(nowdate(), -1),
to_date=add_days(nowdate(), 1),
status=["Rejected"],
)
self.assertEqual(self._rows_for_qi(data), [])
def test_item_code_filter_includes_matching(self):
data = self.run_report(
from_date=add_days(nowdate(), -1),
to_date=add_days(nowdate(), 1),
item_code=["_Test Item"],
)
self.assertEqual(len(self._rows_for_qi(data)), 1)
def test_item_code_filter_excludes_other_item(self):
other_item = frappe.generate_hash(length=10)
data = self.run_report(
from_date=add_days(nowdate(), -1),
to_date=add_days(nowdate(), 1),
item_code=[other_item],
)
self.assertEqual(self._rows_for_qi(data), [])

View File

@@ -0,0 +1,111 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, nowdate
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.work_order_consumed_materials.work_order_consumed_materials import execute
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestWorkOrderConsumedMaterials(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": add_days(nowdate(), -1),
"to_date": add_days(nowdate(), 1),
}
)
filters.update(extra)
return execute(filters)[1]
def make_manufactured_work_order(self, qty=2):
"""Create a submitted WO, stock its raw materials, transfer and fully manufacture it."""
wo = make_wo_order_test_record(production_item="_Test FG Item", qty=qty, company="_Test Company")
for item in wo.required_items:
test_stock_entry.make_stock_entry(
item_code=item.item_code,
target=wo.wip_warehouse,
qty=item.required_qty,
basic_rate=100,
)
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", qty))
transfer.insert()
transfer.submit()
manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", qty))
manufacture.insert()
manufacture.submit()
wo.reload()
return wo
def get_wo_rows(self, data, work_order):
"""The report blanks parent fields after the first raw-material row, so match by raw
material's parent work order instead of the (blanked) `name` column."""
return [row for row in data if row.get("parent") == work_order]
def test_consumed_materials_reported_after_manufacture(self):
wo = self.make_manufactured_work_order(qty=2)
# fully producing the WO consumes exactly the required quantity of each raw material
self.assertEqual(wo.produced_qty, 2)
data = self.run_report()
rows = self.get_wo_rows(data, wo.name)
self.assertEqual(len(rows), len(wo.required_items))
# pair rows to required items by sorting rather than a dict keyed on item code, so
# a BOM with two lines for the same component wouldn't silently collapse to one row
rows_sorted = sorted(rows, key=lambda r: (r["raw_material_item_code"], r["required_qty"]))
items_sorted = sorted(wo.required_items, key=lambda i: (i.item_code, i.required_qty))
for row, item in zip(rows_sorted, items_sorted, strict=True):
self.assertEqual(row["raw_material_item_code"], item.item_code)
self.assertEqual(row["required_qty"], item.required_qty)
self.assertEqual(row["transferred_qty"], item.required_qty)
self.assertEqual(row["consumed_qty"], item.required_qty)
# no over-consumption in a clean full manufacture
self.assertEqual(row["extra_consumed_qty"], 0.0)
self.assertEqual(row["returned_qty"], 0.0)
# parent columns are populated on the first row only
first = rows[0]
self.assertEqual(first["status"], wo.status)
self.assertEqual(first["production_item"], "_Test FG Item")
self.assertEqual(first["qty"], 2)
self.assertEqual(first["produced_qty"], 2)
def test_work_order_filter_scopes_output(self):
wo = self.make_manufactured_work_order(qty=1)
data = self.run_report(name=wo.name)
parents = {row.get("parent") for row in data}
self.assertEqual(parents, {wo.name})
self.assertTrue(data)
def test_draft_work_order_is_excluded(self):
# report only lists WOs in status In Process / Completed / Stopped
draft = make_wo_order_test_record(
production_item="_Test FG Item", qty=1, company="_Test Company", do_not_submit=True
)
data = self.run_report()
self.assertNotIn(draft.name, {row.get("parent") for row in data})
def test_date_range_filter_excludes_work_order(self):
wo = self.make_manufactured_work_order(qty=1)
# positive anchor: the WO shows up within the default (current) window
self.assertIn(wo.name, {row.get("parent") for row in self.run_report()})
# a window that ends before the WO was created must not include it
data = self.run_report(from_date=add_days(nowdate(), -10), to_date=add_days(nowdate(), -5))
self.assertNotIn(wo.name, {row.get("parent") for row in data})

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