Compare commits

..

133 Commits

Author SHA1 Message Date
Khushi Rawat
344f58b98a Merge pull request #56811 from khushi8112/fix/letterhead-footer-print-formats
fix: render letter head footer in print formats
2026-07-03 02:43:26 +05:30
Khushi Rawat
c9145c5ece Merge branch 'develop' into fix/letterhead-footer-print-formats 2026-07-03 02:27:58 +05:30
khushi8112
2d0c0a8c09 fix: add page numbers to print format footer 2026-07-03 02:26:52 +05:30
khushi8112
e60a467972 fix: render letter head footer in print formats 2026-07-03 02:16:59 +05:30
Nabin Hait
e3e3d97a72 Merge pull request #56809 from frappe/chore/test-custom-financial-statement
test: Custom Financial Statement report coverage
2026-07-03 00:33:08 +05:30
Nabin Hait
42c6768b4c test: guard period_keys index access for clearer failure 2026-07-03 00:16:06 +05:30
Nabin Hait
2e13691ffa Merge pull request #56808 from frappe/chore/test-dimension-wise-accounts-balance
test: Dimension-wise Accounts Balance report coverage
2026-07-03 00:14:30 +05:30
Nabin Hait
ae80d29dcf Merge pull request #56792 from frappe/chore/test-timesheet-billing-summary
test: Timesheet Billing Summary report coverage
2026-07-03 00:13:39 +05:30
Nabin Hait
a33f7ead24 Merge pull request #56791 from frappe/chore/test-project-summary
test: Project Summary report coverage
2026-07-03 00:13:28 +05:30
Nabin Hait
817ecaa92f Merge pull request #56790 from frappe/chore/test-lead-owner-efficiency
test: Lead Owner Efficiency report coverage
2026-07-03 00:12:44 +05:30
Nabin Hait
8fd98ccbe2 Merge pull request #56789 from frappe/chore/test-supplier-quotation-comparison
test: Supplier Quotation Comparison report coverage
2026-07-03 00:12:34 +05:30
Nabin Hait
bc82197bd1 Merge pull request #56807 from frappe/chore/test-accounts-payable-summary
test: Accounts Payable Summary report coverage
2026-07-02 23:35:22 +05:30
Nabin Hait
9c53a91b82 test: add simulate=True to draft timesheet for overlap safety 2026-07-02 23:32:02 +05:30
Nabin Hait
21a9f2754e test: unpack report_summary tuple and key labels via _() 2026-07-02 23:31:13 +05:30
Nabin Hait
f0434cadd4 test: guard against missing owner row before subscripting 2026-07-02 23:30:34 +05:30
Nabin Hait
0ba43a17c1 test: strengthen price_per_unit assertion, drop no-op quotation guard 2026-07-02 23:29:48 +05:30
Nabin Hait
4feaacc649 test: Custom Financial Statement report coverage 2026-07-02 23:26:38 +05:30
Nabin Hait
7034dc71e7 test: Dimension-wise Accounts Balance report coverage 2026-07-02 23:24:12 +05:30
Nabin Hait
ed72732bb2 test: Accounts Payable Summary report coverage 2026-07-02 23:21:36 +05:30
Nabin Hait
b12725c4b2 Merge pull request #56778 from frappe/chore/test-quotation-trends
test: Quotation Trends report coverage
2026-07-02 23:07:02 +05:30
Nabin Hait
20928bd600 Merge pull request #56796 from frappe/chore/fix-flaky-bom-cost-valuation-reset
test: fix flaky test_update_bom_cost_in_all_boms via valuation reset
2026-07-02 23:05:22 +05:30
Nabin Hait
15862566a8 Merge pull request #56784 from frappe/chore/test-territory-wise-sales
test: Territory-wise Sales report coverage
2026-07-02 23:04:22 +05:30
Nabin Hait
e446f54f2e Merge pull request #56787 from frappe/chore/test-purchase-analytics
test: Purchase Analytics report coverage
2026-07-02 23:04:10 +05:30
Nabin Hait
cf9f16a921 Merge pull request #56788 from frappe/chore/test-subcontract-order-summary
test: Subcontract Order Summary report coverage
2026-07-02 23:03:57 +05:30
Nabin Hait
6cffa0faeb Merge pull request #56783 from frappe/chore/test-sales-person-wise-transaction-summary
fix: correct filter handling in Sales Person-wise Transaction Summary + tests
2026-07-02 22:58:48 +05:30
Nabin Hait
a075437db7 Merge pull request #56780 from frappe/chore/test-customer-wise-item-price
test: Customer-wise Item Price report coverage
2026-07-02 22:57:19 +05:30
Nabin Hait
9f4914e08f Merge pull request #56781 from frappe/chore/test-sales-person-commission-summary
test: Sales Person Commission Summary report coverage
2026-07-02 22:56:20 +05:30
Mihir Kandoi
c9d712fa49 Merge pull request #56800 from aerele/fix/wo-status-partial-pick
fix(manufacturing): update work order status on partial pick-list transfer
2026-07-02 21:44:46 +05:30
Mihir Kandoi
3345336a5c Merge pull request #56798 from aerele/fix/sre-auto-reserve
fix: skip stock reservation for opted-out production plans
2026-07-02 21:42:54 +05:30
MochaMind
ceadc4f269 fix: sync translations from crowdin (#56673) 2026-07-02 17:08:31 +02:00
Shllokkk
caa4358057 fix: guard against missing DocType in onboarding steps patch (#56804) 2026-07-02 19:33:16 +05:30
Nabin Hait
36f56fa1c3 Merge pull request #56786 from frappe/chore/test-territory-target-variance
test: Territory Target Variance based on Item Group report coverage
2026-07-02 18:09:21 +05:30
Raffael Meyer
5b738b7b0d fix: don't attempt to create SABB for non-serialized / non-batch items (#56627)
* fix: don't attempt to create SABB for non-serialized / non-batch items

* fix(stock): skip serial batch lookup for rows without item code
2026-07-02 12:33:26 +00:00
Sudharsanan11
f85f6be3cf test(manufacturing): add test to validate the work order status on partial pick-list transfer
Cover the pick-list flow where a stock entry moves only one of the work
order's required items: material_transferred_for_manufacturing stays 0 (min
fraction) while the status must move to "in process".
2026-07-02 17:39:33 +05:30
Sudharsanan11
6591ae195d fix(manufacturing): update work order status on partial pick-list transfer
A stock entry created from a pick list has fg_completed_qty=0, so
material_transferred_for_manufacturing is derived from the min-fraction of
item-level transfers. When a pick list moves only some required items, the
un-picked item stays at 0, which zeroes the aggregate and leaves the work
order status at "not started" even though material is already in wip.

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

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

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

* test: add dedicated company fixture for SDBNB tests

* test: use SDBNB company for Sales Invoice SDBNB test

---------

Co-authored-by: Pugazhendhi Velu <pugazhendhi720@gmail.com>
Co-authored-by: Pugazhendhi Velu <126157273+PugazhendhiVelu@users.noreply.github.com>
2026-07-02 13:34:25 +05:30
Mihir Kandoi
c98ca6d2cc Merge pull request #56771 from mihir-kandoi/pg/advisory-lock-postgres-only
fix: restrict repost advisory-lock gate to Postgres
2026-07-02 13:27:49 +05:30
Jatin3128
0a05dd4426 fix: restore Save button on reverse journal entry (#56770)
Reversing a submitted Journal Entry opened a draft with reversal_of set,
which called frm.set_read_only(). That strips the write and submit perms
from frm.perm, so the toolbar never rendered the Save (or later Submit)
button and the reversal could not be saved.

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

Ticket: 72857
2026-07-02 13:17:14 +05:30
Mihir Kandoi
99fbd61bd9 fix: restrict repost advisory-lock gate to Postgres
MariaDB falls back to the existing deadlock-retry path; the advisory-lock
serialization from #56697 now applies on Postgres only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 13:15:12 +05:30
Nabin Hait
b9e321c106 test: cover negative-batch detection in Negative Batch Report 2026-07-02 12:57:16 +05:30
Nabin Hait
27f5235e67 test: cover inconsistent balance detection in Incorrect Balance Qty report 2026-07-02 12:48:29 +05:30
Nabin Hait
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
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
0f812e0686 test: strengthen BOM Operations Time filter isolation coverage 2026-07-02 07:54:52 +05:30
Shllokkk
48aef307f9 fix: surface create payment entries as primary action on row selection 2026-07-02 02:19:38 +05:30
Nabin Hait
145a0b154e fix: apply item and work_order filters in Process Loss Report 2026-07-02 00:34:36 +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
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
75ba81c79a test: add coverage for Cost of Poor Quality Report report 2026-07-01 22:54:16 +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
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
0e8b152c68 fix: avoid double-counting the total in accumulated Consolidated Financial Statement 2026-07-01 21:26:04 +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
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
2a1461c754 test: add coverage for Bank Clearance Summary report 2026-07-01 21:08:05 +05:30
Nabin Hait
cccfdc72c9 test: add coverage for Billed Items To Be Received report 2026-07-01 21:07:45 +05:30
93 changed files with 4044 additions and 855 deletions

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

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,12 @@ def execute(filters=None):
entries = get_entries(filters)
invoice_details = get_invoice_posting_date_map(filters)
report = ReceivablePayableReport(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:

View File

@@ -32,15 +32,15 @@ class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
}
)
filters.update(extra)
return execute(filters)
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):
# Row shape (positional): payment_document, payment_entry(voucher_no),
# party_type, party, posting_date, invoice(against_voucher_no),
# invoice_posting_date, due_date, amount, remarks, age,
# range1, range2, range3, range4, [delay_in_payment]
for row in data:
if row[1] == payment_name:
if row["payment_entry"] == payment_name:
return row
return None
@@ -57,42 +57,60 @@ class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
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()
_columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
# Positional assertions on the row shape.
self.assertEqual(row[2], "Customer")
self.assertEqual(row[4], getdate("2026-06-20")) # payment posting date
self.assertEqual(row[5], invoice.name) # against invoice
self.assertEqual(row[6], getdate("2026-06-01")) # invoice posting date
self.assertEqual(row[8], 1000) # amount
self.assertEqual(row[10], 19) # age = payment date - invoice date
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[11], 1000) # range1 (0-30)
self.assertEqual(row[12], 0) # range2 (30-60)
self.assertEqual(row[13], 0) # range3 (60-90)
self.assertEqual(row[14], 0) # range4 (90 Above)
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()
_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[8], 1000) # amount
self.assertEqual(row[10], 45) # age = payment date - invoice date
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["age"], 45)
# Buckets: 30-60 filled, others empty.
self.assertEqual(row[11], 0) # range1 (0-30)
self.assertEqual(row[12], 1000) # range2 (30-60)
self.assertEqual(row[13], 0) # range3 (60-90)
self.assertEqual(row[14], 0) # range4 (90 Above)
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()

View File

@@ -46,8 +46,9 @@ class TestProfitabilityAnalysis(ERPNextTestSuite):
)
def test_income_expense_and_gross_profit(self):
# bootstrap leaf cost center; clean of committed GL so exact assertions hold
cc = "_Test Cost Center - _TC"
# 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)
@@ -74,7 +75,7 @@ class TestProfitabilityAnalysis(ERPNextTestSuite):
self.assertEqual(parent_row["gross_profit_loss"], 7000)
def test_date_range_excludes_out_of_period_entries(self):
cc = "_Test Cost Center 2 - _TC"
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)
@@ -97,7 +98,8 @@ class TestProfitabilityAnalysis(ERPNextTestSuite):
data = self.run_report()
# the report appends a blank separator row and a totals row at the end
total_row = data[-1]
self.assertEqual(total_row["account"], "'Total'")
# 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

View File

@@ -22,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"), filters.get("date"))
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:
@@ -61,16 +61,35 @@ def get_columns(filters):
return columns
def get_all_shares(shareholder, date):
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."""
transfers = frappe.get_all(
"Share Transfer",
filters={"docstatus": 1, "date": ("<=", date)},
fields=["share_type", "no_of_shares", "rate", "amount", "from_shareholder", "to_shareholder"],
order_by="date",
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:

View File

@@ -42,6 +42,30 @@ class TestShareBalanceReport(ERPNextTestSuite):
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",

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.crm.report.lead_owner_efficiency.lead_owner_efficiency import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestLeadOwnerEfficiency(ERPNextTestSuite):
"""Groups leads by their owner and counts the opportunity/quotation/order funnel
derived from those leads."""
def setUp(self):
# a unique owner keeps the per-owner counts isolated from other tests' leads
self.owner = self.make_user()
def make_user(self):
email = f"lead_owner_{frappe.generate_hash(length=8)}@example.com"
frappe.get_doc(
{"doctype": "User", "email": email, "first_name": "Lead Owner", "send_welcome_email": 0}
).insert()
return email
def make_lead(self):
return frappe.get_doc(
{
"doctype": "Lead",
"lead_name": f"Lead {frappe.generate_hash(length=6)}",
"lead_owner": self.owner,
"company": "_Test Company",
}
).insert()
def run_report(self, **extra):
filters = frappe._dict({"from_date": add_days(today(), -1), "to_date": today()})
filters.update(extra)
return execute(filters)[1]
def owner_row(self, data):
return next((r for r in data if r["lead_owner"] == self.owner), None)
def test_lead_count_grouped_by_owner(self):
self.make_lead()
self.make_lead()
row = self.owner_row(self.run_report())
self.assertIsNotNone(row, "Lead owner missing from report")
self.assertEqual(row["lead_count"], 2)
self.assertEqual(row["opp_count"], 0)
self.assertEqual(row["opp_lead"], 0.0)
def test_opportunity_from_lead_is_counted(self):
lead = self.make_lead()
frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": "Lead",
"party_name": lead.name,
"company": "_Test Company",
"currency": "INR",
}
).insert()
row = self.owner_row(self.run_report())
self.assertIsNotNone(row, "Lead owner missing from report")
self.assertEqual(row["lead_count"], 1)
self.assertEqual(row["opp_count"], 1)
# one opportunity from one lead -> 100% opp/lead conversion
self.assertEqual(row["opp_lead"], 100.0)

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-28 10:20+0000\n"
"PO-Revision-Date: 2026-06-29 20:08\n"
"PO-Revision-Date: 2026-07-01 20:39\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Persian\n"
"MIME-Version: 1.0\n"
@@ -14218,7 +14218,7 @@ msgstr "آدرس فعلی"
#. Label of the current_accommodation_type (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Current Address Is"
msgstr "آدرس فعلی است"
msgstr "آدرس فعلی"
#. Label of the current_amount (Currency) field in DocType 'Stock
#. Reconciliation Item'
@@ -17767,7 +17767,7 @@ msgstr "سود سهام پرداخت شده"
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Divorced"
msgstr "جدا شده"
msgstr "طلاق گرفته"
#. Option for the 'Status' (Select) field in DocType 'Lead'
#: erpnext/crm/doctype/lead/lead.json
@@ -30241,7 +30241,7 @@ msgstr "متخصص بازاریابی"
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Married"
msgstr "متاهل"
msgstr "متأهل"
#: erpnext/setup/setup_wizard/data/marketing_source.txt:7
msgid "Mass Mailing"
@@ -34688,7 +34688,7 @@ msgstr ""
#. Option for the 'Current Address Is' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Owned"
msgstr "مالکیت"
msgstr "ملکی"
#: erpnext/accounts/report/sales_payment_summary/sales_payment_summary.js:29
#: erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py:24
@@ -37162,7 +37162,7 @@ msgstr "آدرس دائمی"
#. 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Permanent Address Is"
msgstr "آدرس دائمی است"
msgstr "آدرس دائمی"
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:73
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:77
@@ -44208,7 +44208,7 @@ msgstr "اجاره"
#. Option for the 'Current Address Is' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Rented"
msgstr "اجاره شده"
msgstr "استیجاری"
#. Label of the reorder_level (Float) field in DocType 'Material Request Item'
#: erpnext/stock/doctype/material_request_item/material_request_item.json
@@ -50940,7 +50940,7 @@ msgstr ""
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Single"
msgstr "تنها"
msgstr "مجرد"
#. Option for the 'Bank Entry Type' (Select) field in DocType 'Bank Transaction
#. Rule'
@@ -60982,7 +60982,7 @@ msgstr "هنگام تهیه فاکتور خرید از سفارش خرید، ب
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Widowed"
msgstr "بیوه"
msgstr "همسر فوت شده"
#. Label of the width (Float) field in DocType 'Shipment Parcel'
#. Label of the width (Float) field in DocType 'Shipment Parcel Template'
@@ -61884,7 +61884,7 @@ msgstr "تراز صفر"
#: erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py:353
msgid "Zero Balance Journal: {0}"
msgstr ""
msgstr "دفتر تراز صفر: {0}"
#: erpnext/regional/report/uae_vat_201/uae_vat_201.py:78
msgid "Zero Rated"
@@ -62008,7 +62008,7 @@ msgstr "fieldname"
#: erpnext/setup/doctype/item_group/item_group.py:49
msgid "for tax category {0}"
msgstr ""
msgstr "برای دسته بندی مالیاتی {0}"
#. Option for the 'Service Provider' (Select) field in DocType 'Currency
#. Exchange Settings'
@@ -62375,7 +62375,7 @@ msgstr ""
#: erpnext/public/js/utils/sales_common.js:336
msgid "{0} cannot be greater than 100"
msgstr ""
msgstr "{0} نمی‌تواند بزرگتر از ۱۰۰ باشد"
#: erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py:136
msgid "{0} cannot be used as a Main Cost Center because it has been used as child in Cost Center Allocation {1}"
@@ -62444,7 +62444,7 @@ msgstr "{0} با موفقیت ارسال شد"
#: erpnext/controllers/buying_controller.py:289
msgid "{0} has submitted assets linked to it. You need to cancel the assets to create purchase return."
msgstr ""
msgstr "{0} دارایی‌های مرتبط با آن را ارسال کرده است. برای ایجاد بازگشت خرید، باید دارایی‌ها را لغو کنید."
#: erpnext/projects/doctype/project/project_dashboard.html:15
msgid "{0} hours"
@@ -62456,7 +62456,7 @@ msgstr "{0} در ردیف {1}"
#: erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py:66
msgid "{0} is a child company."
msgstr ""
msgstr "{0} یک شرکت فرزند است."
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:465
msgid "{0} is a child table and will be deleted automatically with its parent"
@@ -62539,7 +62539,7 @@ msgstr "{0} در {1} فعال نیست"
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:649
msgid "{0} is not running. Cannot trigger events for this document"
msgstr ""
msgstr "{0} در حال اجرا نیست. نمی‌توان رویدادها را برای این سند فعال کرد"
#: erpnext/stock/doctype/material_request/material_request.py:478
msgid "{0} is not the default supplier for any items."
@@ -62547,7 +62547,7 @@ msgstr "{0} تامین کننده پیش‌فرض هیچ موردی نیست."
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:2686
msgid "{0} is on hold until {1}"
msgstr ""
msgstr "{0} تا زمان {1} در حالت انتظار است"
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:68
msgid "{0} is open. Close the POS or cancel the existing POS Opening Entry to create a new POS Opening Entry."
@@ -62722,11 +62722,11 @@ msgstr ""
#: erpnext/accounts/doctype/party_link/party_link.py:53
#: erpnext/accounts/doctype/party_link/party_link.py:63
msgid "{0} {1} is already linked with another {2}"
msgstr ""
msgstr "{0} {1} از قبل به {2} دیگری لینک شده است"
#: erpnext/accounts/doctype/party_link/party_link.py:40
msgid "{0} {1} is already linked with {2} {3}"
msgstr ""
msgstr "{0} {1} از قبل به {2} {3} لینک شده است"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:711
msgid "{0} {1} is associated with {2}, but Party Account is {3}"
@@ -62767,7 +62767,7 @@ msgstr "{0} {1} فعال نیست"
#: erpnext/accounts/doctype/bank_transaction/bank_transaction.py:452
msgid "{0} {1} is not affecting bank account {2}"
msgstr ""
msgstr "{0} {1} تاثیری بر حساب بانکی {2} ندارد"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:688
msgid "{0} {1} is not associated with {2} {3}"

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-28 10:20+0000\n"
"PO-Revision-Date: 2026-06-29 20:08\n"
"PO-Revision-Date: 2026-07-01 20:39\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -8227,7 +8227,7 @@ msgstr "Saldo Historik per Parti"
#: erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py:183
#: erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py:86
msgid "Batchwise Valuation"
msgstr "Partivis Värdering"
msgstr "Partibaserad Värdering"
#. Label of the section_break_3 (Section Break) field in DocType 'Stock
#. Reconciliation Item'
@@ -9319,7 +9319,7 @@ msgstr "Beräkna Uppskatade Ankomst Tider"
#. Settings'
#: erpnext/selling/doctype/selling_settings/selling_settings.json
msgid "Calculate Product Bundle price based on child Item's rates"
msgstr "Beräkna Artikel Paket pris baserat på priser för underordnade artiklar"
msgstr "Beräkna Artikel Paket pris baserat på priser för paket artiklar"
#. Description of the 'Hidden Line (Internal Use Only)' (Check) field in
#. DocType 'Financial Report Row'
@@ -15680,7 +15680,7 @@ msgstr "Avdraget från"
#. Deduction Certificate'
#: erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json
msgid "Deductee Details"
msgstr "Avdragsberättigad Detaljer"
msgstr "Avdragstagare Detaljer"
#. Label of a Workspace Sidebar Item
#: erpnext/workspace_sidebar/taxes.json
@@ -17908,7 +17908,7 @@ msgstr "Uppdatera inte Varianter vid Spara"
#. Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Do not use Batch-wise Valuation"
msgstr "Använd inte Partivis Värdering"
msgstr "Använd inte Partibaserad Värdering"
#: erpnext/assets/doctype/asset/asset.js:957
msgid "Do you really want to restore this scrapped asset?"
@@ -19193,9 +19193,9 @@ msgid "Enabling this will do the following:\n"
msgstr "Om du aktiverar detta kommer följande att hända:\n"
"<ul style=\"padding-left:16px\">\n"
"<li>Pris Kolumn i alla Artikel Paket tabeller redigerbar.</li>\n"
"<li>Beräkna priser för alla <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">artikel paket</a> i Artikel tabell, baserat på priser för dess underordnade artiklar, som anges i Artikel Paket tabell. </li>\n"
"<li>Beräkna priser för alla <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">Artikel Paket</a> i Artikel tabell, baserat på priser för paket artiklar, som anges i Artikel Paket tabell. </li>\n"
"</ul>\n"
"Observera: Om detta är aktiverat kommer uppdatering av pris för artikel paket i artikel tabell inte att ändra dess pris. Det kommer att återställas till det pris som baseras på dess underordnade artiklar när dokumentet sparas."
"Observera: Om detta är aktiverat kommer uppdatering av pris för artikel paket i artikel tabell inte att ändra deras pris. Det kommer att återställas till pris som baseras på paket artiklar när dokument sparas."
#. Label of the encashment_date (Date) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
@@ -33834,7 +33834,7 @@ msgstr "Öppning Faktura Verktyg"
#: erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py:832
#: erpnext/accounts/doctype/sales_invoice/services/gl_composer.py:642
msgid "Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
msgstr "Öppning Fakturan har avrundning justering på {0}.<br><br> '{1}' konto erfordras för att bokföra dessa värden. Ange det i Bolag: {2}.<br><br> Eller så kan '{3}' aktiveras för att inte bokföra någon avrundning justering."
msgstr "Öppning Faktura har avrundning justering på {0}.<br><br> '{1}' konto erfordras för att bokföra dessa värden. Ange det i Bolag: {2}.<br><br> Eller så kan '{3}' aktiveras för att inte bokföra någon avrundning justering."
#: erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool_dashboard.html:8
msgid "Opening Invoices"
@@ -59194,7 +59194,7 @@ msgstr "Använd <strong>Python</strong> filter för att hämta Konton"
#. Label of the use_batchwise_valuation (Check) field in DocType 'Batch'
#: erpnext/stock/doctype/batch/batch.json
msgid "Use Batch-wise Valuation"
msgstr "Använd Partivis Värdering"
msgstr "Använd Partibaserad Värdering"
#. Label of the use_csv_sniffer (Check) field in DocType 'Bank Statement
#. Import'
@@ -60825,7 +60825,7 @@ msgstr "Garanti Utgång (Serienummer)"
#: erpnext/stock/doctype/serial_no/serial_no.json
#: erpnext/support/doctype/warranty_claim/warranty_claim.json
msgid "Warranty Expiry Date"
msgstr "Garanti Utgångsdatum"
msgstr "Garanti Utgång Datum"
#. Label of the warranty_period (Int) field in DocType 'Serial No'
#: erpnext/stock/doctype/serial_no/serial_no.json

View File

@@ -498,11 +498,7 @@ class BOM(WebsiteGenerator):
order_by="sequence_id, idx",
):
child = self.append("operations", row)
# guard against a 0/unset conversion rate (e.g. a foreign-currency BOM with no
# exchange-rate record), mirroring the fallback used elsewhere in this file
child.hour_rate = flt(
row.hour_rate / (flt(self.conversion_rate) or 1), child.precision("hour_rate")
)
child.hour_rate = flt(row.hour_rate / self.conversion_rate, child.precision("hour_rate"))
@staticmethod
def _get_routing_fields():

View File

@@ -881,8 +881,13 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
warehouse_list = [warehouse_list]
if not warehouse_list:
# Reconcile every warehouse the item has a non-zero balance in -- including
# negative balances left by other tests. `get_valuation_rate` averages
# Sum(stock_value)/Sum(actual_qty) across all bins, so a leftover negative
# balance in one warehouse can cancel the reset qty elsewhere and make the
# average collapse to 0, which is a source of flaky BOM-cost failures.
warehouse_list = frappe.get_all(
"Bin", filters={"item_code": item_code, "actual_qty": [">", 0]}, pluck="warehouse"
"Bin", filters={"item_code": item_code, "actual_qty": ["!=", 0]}, pluck="warehouse"
)
if not warehouse_list:

View File

@@ -2322,6 +2322,145 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(len(reserved_entries), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_no_stock_reservation_via_purchase_receipt_when_reserve_stock_disabled(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.material_request.mapper import make_purchase_order
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
frappe.db.set_single_value("Stock Settings", "auto_reserve_stock", 0)
bom_tree = {"FG For SR No Auto Reserve": {"RM For SR No Auto Reserve": {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
warehouse = "_Test Warehouse - _TC"
# reserve_stock is deliberately left unset (defaults to 0): this is what happens when
# "Auto Reserve Stock" is off and nobody ticks "Reserve Stock" on the Production Plan by hand.
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=5,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
for_warehouse=warehouse,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
self.assertEqual(plan.reserve_stock, 0)
plan.submit()
plan.submit_material_request = 1
plan.make_material_request()
material_requests = frappe.get_all(
"Material Request", filters={"production_plan": plan.name}, pluck="name"
)
self.assertGreater(len(material_requests), 0)
for mr_name in list(set(material_requests)):
po = make_purchase_order(mr_name)
po.supplier = "_Test Supplier"
po.submit()
pr = make_purchase_receipt(po.name)
pr.submit()
sre = StockReservation(plan)
reserved_entries = sre.get_reserved_entries("Production Plan", plan.name)
self.assertEqual(len(reserved_entries), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_ignores_production_plans_with_reserve_stock_off_on_shared_purchase_order(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
frappe.db.set_single_value("Stock Settings", "auto_reserve_stock", 0)
warehouse = "_Test Warehouse - _TC"
bom_reserve = create_nested_bom({"FG SR Mixed Reserve": {"RM SR Mixed Reserve": {}}}, prefix="")
bom_skip = create_nested_bom({"FG SR Mixed Skip": {"RM SR Mixed Skip": {}}}, prefix="")
def make_submitted_plan(item_code, reserve_stock):
plan = create_production_plan(
item_code=item_code,
planned_qty=5,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
for_warehouse=warehouse,
reserve_stock=reserve_stock,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
plan.submit()
plan.submit_material_request = 1
plan.make_material_request()
return plan
plan_reserve = make_submitted_plan(bom_reserve.item, reserve_stock=1)
plan_skip = make_submitted_plan(bom_skip.item, reserve_stock=0)
self.assertEqual(plan_reserve.reserve_stock, 1)
self.assertEqual(plan_skip.reserve_stock, 0)
mr_reserve = frappe.get_all(
"Material Request", filters={"production_plan": plan_reserve.name}, pluck="name"
)[0]
mr_skip = frappe.get_all(
"Material Request", filters={"production_plan": plan_skip.name}, pluck="name"
)[0]
# One Purchase Order pulling rows from both Material Requests, so the Purchase Receipt made
# from it has both a reservable and a non-reservable Production Plan reference in `doc.items`.
po = frappe.new_doc("Purchase Order")
po.supplier = "_Test Supplier"
po.company = plan_reserve.company
po.schedule_date = nowdate()
for mr_name in (mr_reserve, mr_skip):
mr = frappe.get_doc("Material Request", mr_name)
for item in mr.items:
po.append(
"items",
{
"item_code": item.item_code,
"qty": item.qty,
"rate": 100,
"schedule_date": nowdate(),
"warehouse": warehouse,
"material_request": mr.name,
"material_request_item": item.name,
},
)
po.submit()
pr = make_purchase_receipt(po.name)
pr.submit()
reserved_for_plan_reserve = StockReservation(plan_reserve).get_reserved_entries(
"Production Plan", plan_reserve.name
)
reserved_for_plan_skip = StockReservation(plan_skip).get_reserved_entries(
"Production Plan", plan_skip.name
)
self.assertGreater(len(reserved_for_plan_reserve), 0)
self.assertEqual(len(reserved_for_plan_skip), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_restored_on_work_order_cancel(self):
# Spec #5 (cancellation path): when a Work Order created from a Production Plan is cancelled,
# the reservation that was transferred PP -> WO must flow back to the still-open Production

View File

@@ -85,37 +85,6 @@ class TestRouting(ERPNextTestSuite):
self.assertEqual(bom_doc.operations[0].time_in_mins, 30)
self.assertEqual(bom_doc.operations[1].time_in_mins, 20)
def test_get_routing_survives_zero_conversion_rate(self):
# A foreign-currency BOM with no exchange-rate record leaves conversion_rate at 0.
# get_routing() divides the operation hour rate by it and used to raise
# ZeroDivisionError; it must now fall back to a rate of 1.
operations = [
{
"operation": "Test Operation A",
"workstation": "_Test Workstation A",
"hour_rate_rent": 300,
"hour_rate_labour": 750,
"time_in_mins": 30,
},
]
setup_operations(operations)
routing_doc = create_routing(
routing_name="Zero Rate Route",
operations=[
{"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 30}
],
)
bom = frappe.new_doc("BOM")
bom.routing = routing_doc.name
bom.conversion_rate = 0
bom.get_routing() # must not raise ZeroDivisionError
self.assertTrue(bom.operations)
# with the 0 rate falling back to 1, the hour rate is carried over unchanged
self.assertEqual(bom.operations[0].hour_rate, routing_doc.operations[0].hour_rate)
def setup_operations(rows):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation

View File

@@ -132,7 +132,9 @@ class StatusService:
status = (
"In Process"
if flt(self.doc.material_transferred_for_manufacturing) > 0 or self.doc.skip_transfer
if flt(self.doc.material_transferred_for_manufacturing) > 0
or self.doc.skip_transfer
or self._has_transferred_material()
else "Not Started"
)
precision = frappe.get_precision("Work Order", "produced_qty")
@@ -141,6 +143,26 @@ class StatusService:
status = "Completed"
return status
def _has_transferred_material(self):
"""True if any raw material was transferred against this work order via a pick list
(these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
qty = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(Sum(ste_child.transfer_qty))
.where(
(ste.work_order == self.doc.name)
& (ste.docstatus == 1)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
& (ste.pick_list.isnotnull())
)
).run()[0][0]
return flt(qty) > 0
def _is_partial_skip_transfer(self):
return bool(
self.doc.skip_transfer

View File

@@ -1528,6 +1528,38 @@ class TestWorkOrder(ERPNextTestSuite):
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
def test_status_in_process_when_only_one_required_item_transferred(self):
"""Stock Entry created from a Pick List that picked only one of the required items:
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
still move to In Process because material is already in WIP."""
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
from erpnext.stock.doctype.pick_list.mapper import create_stock_entry
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
)
pick_list = create_pick_list(work_order.name, for_qty=work_order.qty)
# pick only _Test Item; the other required item is left out of this pick list
pick_list.pick_manually = 1
pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"]
pick_list.save()
pick_list.submit()
stock_entry = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(stock_entry.fg_completed_qty, 0.0)
stock_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",

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

@@ -9,77 +9,93 @@ 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()
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)
self.bom = build_bom_with_operation(self.fg_item, self.rm_item, OPERATION, WORKSTATION)
def run_report(self, **extra):
filters = frappe._dict({"bom_id": [self.bom.name]})
filters.update(extra)
return execute(filters)[1]
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()
rows = self.run_report(bom_id=[self.bom.name])
bom_rows = [row for row in rows if row.name == self.bom.name]
self.assertEqual(len(bom_rows), 1)
row = bom_rows[0]
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_scopes_to_bom(self):
rows = self.run_report(item_code=self.fg_item)
def test_item_code_filter_includes_matching_and_excludes_other(self):
other_bom = self.build_other_bom()
self.assertTrue(rows)
self.assertTrue(all(row.item == self.fg_item for row in rows))
self.assertIn(self.bom.name, {row.name for row in rows})
# 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)
def test_workstation_filter(self):
matching = self.run_report(workstation=WORKSTATION)
self.assertIn(self.bom.name, {row.name for row in matching})
# 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)
other_workstation = ensure_other_workstation()
non_matching = self.run_report(workstation=other_workstation)
self.assertNotIn(self.bom.name, {row.name for row in non_matching})
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, do_not_submit=True
make_item(properties={"is_stock_item": 1}).name,
self.rm_item,
OPERATION,
WORKSTATION,
do_not_submit=True,
)
rows = execute(frappe._dict({"bom_id": [draft_bom.name]}))[1]
rows = self.run_report(bom_id=[draft_bom.name])
self.assertEqual(rows, [])
def ensure_workstation_and_operation():
if not frappe.db.exists("Workstation", WORKSTATION):
frappe.get_doc({"doctype": "Workstation", "workstation_name": WORKSTATION}).insert(
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(
if not frappe.db.exists("Operation", operation):
frappe.get_doc({"doctype": "Operation", "name": operation, "workstation": workstation}).insert(
ignore_permissions=True
)
def ensure_other_workstation():
name = "_Test BOM Ops Time Workstation 2"
if not frappe.db.exists("Workstation", name):
frappe.get_doc({"doctype": "Workstation", "workstation_name": name}).insert(ignore_permissions=True)
return name
def build_bom_with_operation(fg_item, rm_item, do_not_submit=False):
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],
@@ -89,8 +105,8 @@ def build_bom_with_operation(fg_item, rm_item, do_not_submit=False):
bom.append(
"operations",
{
"operation": OPERATION,
"workstation": WORKSTATION,
"operation": operation,
"workstation": workstation,
"time_in_mins": TIME_IN_MINS,
"hour_rate": 100,
},

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

@@ -73,7 +73,7 @@ class TestProcessLossReport(ERPNextTestSuite):
self.assertEqual(wo_order.process_loss_qty, 1)
self.assertEqual(wo_order.produced_qty, 4)
data = self.run_report(work_order=wo_order.name)
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")
@@ -93,26 +93,22 @@ class TestProcessLossReport(ERPNextTestSuite):
self.assertEqual(wo_order.process_loss_qty, 0)
self.assertEqual(wo_order.produced_qty, 5)
data = self.run_report(work_order=wo_order.name)
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_and_work_order_filters_are_ineffective(self):
"""BUG: the `item` and `work_order` filters in process_loss_report.get_data
call `query.where(...)` without reassigning the result. frappe's query
builder is immutable, so `.where()` returns a new query and these extra
conditions are silently dropped. A non-matching item filter therefore fails
to exclude the row. This test documents the current (buggy) behaviour; if the
report is fixed to reassign the query, update the assertion below to
`assertIsNone`.
"""
def test_item_filter_scopes_rows(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
# A non-matching item filter should exclude the row, but currently does not.
data = self.run_report(item="_Test FG Item 2")
self.assertIsNotNone(
self.find_row(data, wo_order.name),
"Filter bug regressed/fixed: `item` filter now takes effect - update this test",
)
# 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})

View File

@@ -34,6 +34,7 @@ def complete_onboarding_steps_if_record_exists(steps):
if (
step.action == "Create Entry"
and step.reference_document
and frappe.db.exists("DocType", step.reference_document)
and frappe.get_all(step.reference_document, limit=1)
):
frappe.db.set_value("Onboarding Step", step.name, "is_complete", 1, update_modified=False)

View File

@@ -0,0 +1,65 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import _
from erpnext.projects.report.project_summary.project_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestProjectSummary(ERPNextTestSuite):
"""Lists projects with their total / completed / overdue task counts."""
def make_project(self):
return frappe.get_doc(
{
"doctype": "Project",
"project_name": f"_Test PS {frappe.generate_hash(length=6)}",
"company": "_Test Company",
}
).insert()
def make_task(self, project, status="Open"):
task = frappe.get_doc(
{
"doctype": "Task",
"subject": f"Task {frappe.generate_hash(length=6)}",
"project": project.name,
}
).insert()
if status != "Open":
# set the status directly; the report counts tasks by their stored status
frappe.db.set_value("Task", task.name, "status", status)
return task
def run_report(self, project):
return execute(frappe._dict({"name": project.name}))
def project_row(self, project):
_columns, data, *_rest = self.run_report(project)
return next((r for r in data if r["name"] == project.name), None)
def test_task_counts(self):
project = self.make_project()
self.make_task(project, "Completed")
self.make_task(project, "Completed")
self.make_task(project, "Open")
self.make_task(project, "Overdue")
row = self.project_row(project)
self.assertIsNotNone(row, "Project missing from report")
self.assertEqual(row["total_tasks"], 4)
self.assertEqual(row["completed_tasks"], 2)
self.assertEqual(row["overdue_tasks"], 1)
def test_report_summary_totals(self):
project = self.make_project()
self.make_task(project, "Completed")
self.make_task(project, "Open")
_columns, _data, _message, _chart, report_summary = self.run_report(project)
summary = {s["label"]: s["value"] for s in report_summary}
self.assertEqual(summary[_("Total Tasks")], 2)
self.assertEqual(summary[_("Completed Tasks")], 1)
self.assertEqual(summary[_("Overdue Tasks")], 0)

View File

@@ -0,0 +1,64 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.projects.doctype.timesheet.test_timesheet import make_timesheet
from erpnext.projects.report.timesheet_billing_summary.timesheet_billing_summary import execute
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
class TestTimesheetBillingSummary(ERPNextTestSuite):
"""Lists submitted Timesheet Detail rows with working/billing hours and amount,
optionally grouped by date/project/employee."""
def setUp(self):
self.employee = make_employee("timesheet_billing@example.com", company="_Test Company")
self.project = frappe.get_doc(
{
"doctype": "Project",
"project_name": f"_Test TBS {frappe.generate_hash(length=6)}",
"company": "_Test Company",
}
).insert()
def make_ts(self, is_billable=1):
return make_timesheet(
self.employee, simulate=True, is_billable=is_billable, project=self.project.name
)
def run_report(self, **extra):
filters = frappe._dict({"company": "_Test Company", "employee": self.employee})
filters.update(extra)
return execute(filters)[1]
def test_billable_timesheet_row(self):
ts = self.make_ts(is_billable=1)
detail = ts.time_logs[0]
rows = [r for r in self.run_report() if r.get("timesheet") == ts.name]
self.assertTrue(rows, "Timesheet missing from report")
row = rows[0]
self.assertEqual(row["hours"], 2)
self.assertEqual(row["billing_hours"], detail.billing_hours)
self.assertEqual(row["billing_amount"], detail.billing_amount)
self.assertEqual(row["project"], self.project.name)
def test_group_by_project_sums_hours(self):
self.make_ts(is_billable=1)
data = self.run_report(group_by="project")
group_rows = [r for r in data if r.get("is_group") and r.get("project") == self.project.name]
self.assertTrue(group_rows, "Grouped project row missing")
self.assertEqual(group_rows[0]["hours"], 2)
def test_draft_excluded_unless_requested(self):
ts = make_timesheet(
self.employee, simulate=True, is_billable=1, project=self.project.name, do_not_submit=True
)
# submitted-only by default: the draft timesheet is absent
self.assertNotIn(ts.name, {r.get("timesheet") for r in self.run_report()})
# ... but included when draft timesheets are requested
self.assertIn(ts.name, {r.get("timesheet") for r in self.run_report(include_draft_timesheets=1)})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,69 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.selling.report.customer_wise_item_price.customer_wise_item_price import execute
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
PRICE_LIST = "Standard Selling"
class TestCustomerWiseItemPrice(ERPNextTestSuite):
"""The report lists sales items with the selling rate from the customer's price
list and the available stock (summed across warehouses)."""
def setUp(self):
self.item = make_item(properties={"is_stock_item": 1, "is_sales_item": 1}).name
self.customer = self.create_customer()
frappe.get_doc(
{
"doctype": "Item Price",
"item_code": self.item,
"price_list": PRICE_LIST,
"selling": 1,
"price_list_rate": 250,
}
).insert()
make_stock_entry(item_code=self.item, to_warehouse="Stores - _TC", qty=10, rate=100)
def create_customer(self):
name = "_Test CWIP Customer"
if not frappe.db.exists("Customer", name):
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": name,
"customer_group": "_Test Customer Group",
"territory": "_Test Territory",
"default_price_list": PRICE_LIST,
}
).insert()
return name
def run_report(self, **extra):
filters = frappe._dict({"customer": self.customer})
filters.update(extra)
return execute(filters)[1]
def test_customer_filter_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({}))
def test_selling_rate_and_available_stock_for_item(self):
rows = self.run_report(item=self.item)
row = next((r for r in rows if r["item_code"] == self.item), None)
self.assertIsNotNone(row, "Sales item missing from report")
self.assertEqual(row["item_name"], frappe.db.get_value("Item", self.item, "item_name"))
self.assertEqual(row["selling_rate"], 250) # from the customer's price list
self.assertEqual(row["available_stock"], 10) # stocked into Stores - _TC
self.assertEqual(row["price_list"], PRICE_LIST)
def test_item_filter_scopes_to_single_item(self):
other = make_item(properties={"is_stock_item": 1, "is_sales_item": 1}).name
item_codes = {r["item_code"] for r in self.run_report(item=self.item)}
self.assertIn(self.item, item_codes)
self.assertNotIn(other, item_codes)

View File

@@ -0,0 +1,88 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.report.quotation_trends.quotation_trends import execute
from erpnext.tests.utils import ERPNextTestSuite
FISCAL_YEAR = "_Test Fiscal Year 2026"
TXN_DATE = "2026-06-01"
class TestQuotationTrends(ERPNextTestSuite):
"""The trends report buckets submitted Quotation quantities/amounts by period
(Yearly/Monthly) for the chosen `based_on` dimension (Item, Customer, ...)."""
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": FISCAL_YEAR,
"based_on": "Item",
"period": "Yearly",
}
)
filters.update(extra)
result = execute(filters)
columns, data = result[0], result[1]
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):
"""Value at column `col_label` for the row whose `key_label` column equals
`key_value`, or 0 when that row doesn't 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)
make_quotation(item="_Test Item", qty=4, rate=200, transaction_date=TXN_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 quotation hits "Jun (Qty)"/"(Amt)".
labels, before = self.run_report(period="Monthly")
before_jun_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels)
before_jun_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels)
before_may_qty = self._cell(before, "Item", "_Test Item", "May (Qty)", labels)
make_quotation(item="_Test Item", qty=3, rate=100, transaction_date=TXN_DATE)
labels, after = self.run_report(period="Monthly")
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_jun_qty, 3)
# the amount path is a separate SUM(base_net_amount) case, so assert it too
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_jun_amt, 300)
# nothing was quoted in May, so that bucket is unchanged
self.assertEqual(self._cell(after, "Item", "_Test Item", "May (Qty)", labels) - before_may_qty, 0)
def test_based_on_customer_groups_amount_by_party(self):
# based_on Customer keys rows on the "Party" column (the customer id)
labels, before = self.run_report(based_on="Customer")
amt_col = f"{FISCAL_YEAR} (Amt)"
before_amt = self._cell(before, "Party", "_Test Customer", amt_col, labels)
make_quotation(
party_name="_Test Customer", item="_Test Item", qty=2, rate=150, transaction_date=TXN_DATE
)
labels, after = self.run_report(based_on="Customer")
self.assertEqual(self._cell(after, "Party", "_Test Customer", amt_col, labels) - before_amt, 300)

View File

@@ -0,0 +1,85 @@
# 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.selling.report.sales_person_commission_summary.sales_person_commission_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesPersonCommissionSummary(ERPNextTestSuite):
"""The report joins a sales document (Sales Invoice/Order/Delivery Note) with its
Sales Team rows, listing each sales person's contribution and commission."""
def setUp(self):
# reuse the bootstrap sales persons (under the "Sales Team" group)
self.sales_person = "_Test Sales Person"
def make_invoice_with_commission(self, percentage=100, commission_rate=5, incentives=50):
si = create_sales_invoice(rate=1000, qty=1, do_not_save=True, posting_date="2026-06-01")
si.append(
"sales_team",
{
"sales_person": self.sales_person,
"allocated_percentage": percentage,
"commission_rate": commission_rate,
"incentives": incentives,
},
)
si.insert()
si.submit()
si.reload() # reflect any values recomputed on submit
return si
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"doc_type": "Sales Invoice",
"sales_person": self.sales_person,
# scope to this test's posting date so the query isn't unbounded over
# every invoice for the shared sales person
"from_date": "2026-06-01",
"to_date": "2026-06-01",
}
)
filters.update(extra)
return execute(filters)[1]
def test_doc_type_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"}))
def test_commission_row_matches_sales_team_entry(self):
si = self.make_invoice_with_commission(percentage=100, commission_rate=5, incentives=50)
team = si.sales_team[0]
rows = self.run_report()
row = next((r for r in rows if r[0] == si.name), None)
self.assertIsNotNone(row, "Invoice with commission missing from report")
# row: name, customer, territory, posting_date, base_net_amount, sales_person,
# allocated_percentage, commission_rate, allocated_amount, incentives
self.assertEqual(row[1], si.customer)
self.assertEqual(row[4], si.base_net_total)
self.assertEqual(row[5], self.sales_person)
self.assertEqual(row[6], team.allocated_percentage)
self.assertEqual(row[7], team.commission_rate)
self.assertEqual(row[8], team.allocated_amount)
self.assertEqual(row[9], team.incentives)
def test_appends_total_row(self):
self.make_invoice_with_commission()
rows = self.run_report()
# the report appends a blank total row after one or more real data rows
self.assertGreaterEqual(len(rows), 2)
self.assertTrue(any(r[0] for r in rows[:-1]), "expected real data rows before the total row")
self.assertEqual(rows[-1], [""] * len(rows[0]))
def test_sales_person_filter_scopes_rows(self):
si = self.make_invoice_with_commission()
filtered = self.run_report(sales_person="_Test Sales Person 1")
self.assertNotIn(si.name, {r[0] for r in filtered if r[0]})

View File

@@ -183,8 +183,22 @@ def get_entries(filters):
.as_("contribution_amt")
)
# Only pass valid document-field filters to get_query; report-specific keys such as
# doc_type / sales_person / item_group are handled separately below.
doc_filters = {"docstatus": 1}
for field in ["company", "customer", "territory"]:
if filters.get(field):
doc_filters[field] = filters.get(field)
if filters.get("from_date") and filters.get("to_date"):
doc_filters[date_field] = ["between", [filters.get("from_date"), filters.get("to_date")]]
elif filters.get("from_date"):
doc_filters[date_field] = [">=", filters.get("from_date")]
elif filters.get("to_date"):
doc_filters[date_field] = ["<=", filters.get("to_date")]
query = (
frappe.get_query(dt, filters=filters, ignore_permissions=False)
frappe.get_query(dt, filters=doc_filters, ignore_permissions=False)
.join(dt_item)
.on(dt.name == dt_item.parent)
.join(st)
@@ -203,48 +217,29 @@ def get_entries(filters):
contribution_amt_case,
)
.where(st.parenttype == doc_type)
.where(dt.docstatus == 1)
)
if filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
sp = frappe.qb.DocType("Sales Person")
query = query.where(
st.sales_person.isin(frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt)))
)
# only resolve items when an item_group/brand filter is set; otherwise get_items
# would return every item in the system and add a huge IN() clause on each run
if filters.get("item_group") or filters.get("brand"):
items = get_items(filters)
if not items:
# the item_group/brand filter matched nothing -> no rows
return []
query = query.where(dt_item.item_code.isin([d[0] for d in items]))
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
return query.run(as_dict=True)
def get_conditions(filters, date_field):
conditions = [""]
values = []
for field in ["company", "customer", "territory"]:
if filters.get(field):
conditions.append(f"dt.{field}=%s")
values.append(filters[field])
if filters.get("sales_person"):
lft, rgt = frappe.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
conditions.append(
f"exists(select name from `tabSales Person` where lft >= {lft} and rgt <= {rgt} and name=st.sales_person)"
)
if filters.get("from_date"):
conditions.append(f"dt.{date_field}>=%s")
values.append(filters["from_date"])
if filters.get("to_date"):
conditions.append(f"dt.{date_field}<=%s")
values.append(filters["to_date"])
items = get_items(filters)
if items:
conditions.append("dt_item.item_code in (%s)" % ", ".join(["%s"] * len(items)))
values += items
else:
# return empty result, if no items are fetched after filtering on 'item group' and 'brand'
conditions.append("dt_item.item_code = Null")
return " and ".join(conditions), values
def get_items(filters):
item = qb.DocType("Item")

View File

@@ -0,0 +1,69 @@
# 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.selling.report.sales_person_wise_transaction_summary.sales_person_wise_transaction_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesPersonWiseTransactionSummary(ERPNextTestSuite):
"""Item-level summary joining a sales document with its Sales Team rows, showing
each sales person's contributed qty and amount per item line."""
def setUp(self):
self.sales_person = "_Test Sales Person"
def make_invoice_with_commission(self, qty=5, rate=200, percentage=100):
si = create_sales_invoice(
item="_Test Item", qty=qty, rate=rate, do_not_save=True, posting_date="2026-06-01"
)
si.append("sales_team", {"sales_person": self.sales_person, "allocated_percentage": percentage})
si.insert()
si.submit()
return si
def run_report(self, **extra):
filters = frappe._dict(
{"company": "_Test Company", "doc_type": "Sales Invoice", "sales_person": self.sales_person}
)
filters.update(extra)
return execute(filters)[1]
def test_doc_type_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"}))
def test_invalid_doc_type_throws(self):
self.assertRaises(
frappe.ValidationError,
execute,
frappe._dict({"company": "_Test Company", "doc_type": "Purchase Invoice"}),
)
def test_item_line_contribution(self):
si = self.make_invoice_with_commission(qty=5, rate=200, percentage=100)
item = si.items[0]
rows = self.run_report()
row = next((r for r in rows if r[0] == si.name and r[5] == "_Test Item"), None)
self.assertIsNotNone(row, "Invoice item line missing from report")
# row: name, customer, territory, warehouse, posting_date, item_code, item_group,
# brand, stock_qty, base_net_amount, sales_person, allocated_percentage,
# contributed_qty, contribution_amt, currency
self.assertEqual(row[1], si.customer)
self.assertEqual(row[8], item.stock_qty)
self.assertEqual(row[9], item.base_net_amount)
self.assertEqual(row[10], self.sales_person)
self.assertEqual(row[11], 100)
self.assertEqual(row[12], item.stock_qty * 100 / 100) # contributed qty
self.assertEqual(row[13], item.base_net_amount * 100 / 100) # contribution amount
def test_appends_total_row(self):
self.make_invoice_with_commission()
rows = self.run_report()
self.assertTrue(rows)
self.assertEqual(rows[-1], [""] * len(rows[0]))

View File

@@ -0,0 +1,68 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt, nowdate
from erpnext.accounts.utils import get_fiscal_year
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_person_target_variance_based_on_item_group.test_sales_person_target_variance_based_on_item_group import (
create_target_distribution,
)
from erpnext.selling.report.territory_target_variance_based_on_item_group.territory_target_variance_based_on_item_group import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestTerritoryTargetVarianceBasedOnItemGroup(ERPNextTestSuite):
def setUp(self):
self.fiscal_year = get_fiscal_year(nowdate())[0]
def test_achieved_target_and_variance(self):
distribution = create_target_distribution(self.fiscal_year)
territory = create_territory_with_target(
"_Test Target Territory", self.fiscal_year, distribution.name, target_qty=50
)
# a Sales Order in that territory contributes to the achieved quantity
so = make_sales_order(rate=1000, qty=20, do_not_submit=True)
so.territory = territory.name
so.submit()
result = execute(
frappe._dict(
{
"fiscal_year": self.fiscal_year,
"doctype": "Sales Order",
"period": "Yearly",
"target_on": "Quantity",
}
)
)[1]
# no item_group is set on the target, so the report emits exactly one row per
# territory -- assert all three figures against that single row
rows = [frappe._dict(r) for r in result if r.get("territory") == territory.name]
self.assertEqual(len(rows), 1, "expected exactly one row for the target territory")
row = rows[0]
self.assertEqual(flt(row.total_target, 2), 50)
self.assertEqual(flt(row.total_achieved, 2), 20)
self.assertEqual(flt(row.total_variance, 2), -30)
def create_territory_with_target(name, fiscal_year, distribution_id, target_qty=50):
doc = frappe.new_doc("Territory")
doc.territory_name = name
doc.parent_territory = "All Territories"
doc.is_group = 0
doc.append(
"targets",
{
"fiscal_year": fiscal_year,
"target_qty": target_qty,
"target_amount": 30000,
"distribution_id": distribution_id,
},
)
return doc.insert()

View File

@@ -0,0 +1,62 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.report.territory_wise_sales.territory_wise_sales import execute
from erpnext.tests.utils import ERPNextTestSuite
TERRITORY = "_Test Territory"
class TestTerritoryWiseSales(ERPNextTestSuite):
"""The report walks the Opportunity -> Quotation -> Sales Order -> Sales Invoice
funnel and totals each stage's amount per territory.
These tests cover the Opportunity and Quotation stages; the Sales Order and
Sales Invoice (order_amount / billing_amount) stages are not yet exercised."""
def make_opportunity(self, amount=5000):
return frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": "Customer",
"party_name": "_Test Customer",
"territory": TERRITORY,
"company": "_Test Company",
"currency": "INR",
"opportunity_amount": amount,
"transaction_date": "2026-06-01",
}
).insert()
def make_quotation_for(self, opportunity, qty, rate):
qo = make_quotation(item="_Test Item", qty=qty, rate=rate, do_not_save=True)
qo.opportunity = opportunity.name
qo.insert()
qo.submit()
return qo
def amount_for(self, territory, field):
for row in execute(frappe._dict({"company": "_Test Company"}))[1]:
if row["territory"] == territory:
return row[field]
return 0
def test_opportunity_amount_grouped_by_territory(self):
before = self.amount_for(TERRITORY, "opportunity_amount")
opp = self.make_opportunity(5000)
self.assertEqual(opp.territory, TERRITORY)
after = self.amount_for(TERRITORY, "opportunity_amount")
self.assertEqual(after - before, 5000)
def test_quotation_amount_flows_from_opportunity(self):
before = self.amount_for(TERRITORY, "quotation_amount")
opp = self.make_opportunity()
quotation = self.make_quotation_for(opp, qty=2, rate=500)
after = self.amount_for(TERRITORY, "quotation_amount")
self.assertEqual(after - before, quotation.base_grand_total)

View File

@@ -339,7 +339,7 @@ erpnext.company.setup_queries = function (frm) {
],
[
"stock_delivered_but_not_billed",
{ root_type: "Liability", account_type: "Stock Delivered But Not Billed" },
{ root_type: "Asset", account_type: "Stock Delivered But Not Billed" },
],
[
"service_received_but_not_billed",

View File

@@ -132,6 +132,7 @@
"default_purchase_price_variance_account",
"default_manufacturing_variance_account",
"stock_received_but_not_billed",
"enable_stock_delivered_but_not_billed",
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
"default_provisional_account",
@@ -1048,18 +1049,28 @@
},
{
"default": "0",
"depends_on": "enable_stock_delivered_but_not_billed",
"fieldname": "disable_sdbnb_in_sr",
"fieldtype": "Check",
"label": "Disable Stock Delivered But Not Billed in Sales Return",
"no_copy": 1
},
{
"depends_on": "enable_stock_delivered_but_not_billed",
"fieldname": "stock_delivered_but_not_billed",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Stock Delivered But Not Billed",
"mandatory_depends_on": "enable_stock_delivered_but_not_billed",
"no_copy": 1,
"options": "Account"
},
{
"default": "0",
"description": "If enabled, the value of goods delivered before invoicing will be recorded in the Stock Delivered But Not Billed account.",
"fieldname": "enable_stock_delivered_but_not_billed",
"fieldtype": "Check",
"label": "Enable Stock Delivered But Not Billed"
}
],
"grid_page_length": 50,

View File

@@ -100,6 +100,7 @@ class Company(NestedSet):
enable_item_wise_inventory_account: DF.Check
enable_perpetual_inventory: DF.Check
enable_provisional_accounting_for_non_stock_items: DF.Check
enable_stock_delivered_but_not_billed: DF.Check
exception_budget_approver_role: DF.Link | None
exchange_gain_loss_account: DF.Link | None
existing_company: DF.Link | None
@@ -186,6 +187,64 @@ class Company(NestedSet):
self.validate_inventory_account_settings()
self.cant_change_valuation_method()
self.validate_pending_reposts(old_doc)
self.validate_sdbnb_configuration()
def validate_outstanding_sdbnb_transactions(self, account):
GLEntry = frappe.qb.DocType("GL Entry")
DeliveryNote = frappe.qb.DocType("Delivery Note")
delivery_notes = (
frappe.qb.from_(GLEntry)
.join(DeliveryNote)
.on((GLEntry.voucher_type == "Delivery Note") & (GLEntry.voucher_no == DeliveryNote.name))
.select(DeliveryNote.name)
.where(
(GLEntry.is_cancelled == 0)
& (GLEntry.company == self.name)
& (GLEntry.account == account)
& (DeliveryNote.per_billed < 100)
& (DeliveryNote.docstatus == 1)
& (DeliveryNote.status.isin(["To Bill", "Partially Billed"]))
)
.distinct()
.run(pluck=True)
)
if delivery_notes:
dn_links = ", ".join(get_link_to_form("Delivery Note", dn) for dn in delivery_notes[:10])
frappe.throw(
_(
"Stock Delivered But Not Billed Account cannot be changed or disabled since account {0} contains outstanding Delivery Notes: {1}"
).format(
bold(account),
dn_links,
)
)
def validate_sdbnb_configuration(self):
if self.get("__islocal"):
return
if self.enable_stock_delivered_but_not_billed and not self.stock_delivered_but_not_billed:
frappe.throw(_("Please select Stock Delivered But Not Billed Account"))
doc_before_save = self.get_doc_before_save()
if not (doc_before_save and doc_before_save.stock_delivered_but_not_billed):
return
account_changed = (
self.stock_delivered_but_not_billed != doc_before_save.stock_delivered_but_not_billed
)
feature_disabled = (
doc_before_save.enable_stock_delivered_but_not_billed
and not self.enable_stock_delivered_but_not_billed
)
if account_changed or feature_disabled:
self.validate_outstanding_sdbnb_transactions(doc_before_save.stock_delivered_but_not_billed)
def cant_change_valuation_method(self):
doc_before_save = self.get_doc_before_save()

View File

@@ -10,7 +10,11 @@ from frappe.utils import random_string
from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import (
get_charts_for_country,
)
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.setup.doctype.company.company import get_default_company_address
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
@@ -234,6 +238,44 @@ class TestCompany(ERPNextTestSuite):
after = get_all_transactions_annual_history(company).get(key, 0)
self.assertEqual(after - before, 2)
def test_sdbnb_validation_requires_account_when_enabled(self):
company = get_test_company()
company.enable_stock_delivered_but_not_billed = 1
company.stock_delivered_but_not_billed = None
with self.assertRaises(frappe.ValidationError):
company.save()
def test_disable_sdbnb_with_outstanding_delivery_note_fails(self):
company = get_test_company()
item_code = create_stock_item_with_inventory()
create_outstanding_delivery_note(item_code)
company.enable_stock_delivered_but_not_billed = 0
with self.assertRaises(frappe.ValidationError):
company.save()
def test_cannot_change_sdbnb_account_with_outstanding_delivery_note(self):
company = get_test_company()
item_code = create_stock_item_with_inventory()
create_outstanding_delivery_note(item_code)
new_account = create_account(
account_name="Stock Delivered But Not Billed - New",
account_type="Stock Delivered But Not Billed",
parent_account="Stock Assets - _TSDBNB",
company=company.name,
)
company.stock_delivered_but_not_billed = new_account
with self.assertRaises(frappe.ValidationError):
company.save()
def test_demo_data(self):
from erpnext.setup.demo import clear_demo_data, setup_demo_data
@@ -297,3 +339,49 @@ def create_test_lead_in_company(company):
lead.company = company
lead.save()
return lead.name
def get_test_company():
if frappe.db.exists("Company", "_Test SDBNB Company"):
return frappe.get_doc("Company", "_Test SDBNB Company")
return frappe.get_doc(
{
"doctype": "Company",
"company_name": "_Test SDBNB Company",
"abbr": "_TSDBNB",
"country": "India",
"default_currency": "INR",
"enable_perpetual_inventory": 1,
"enable_stock_delivered_but_not_billed": 1,
}
).insert()
def create_stock_item_with_inventory():
item_code = make_item(
"SDBNB Test Item",
properties={"is_stock_item": 1},
).name
make_stock_entry(
item_code=item_code,
target="Stores - _TSDBNB",
qty=10,
basic_rate=100,
company="_Test SDBNB Company",
)
return item_code
def create_outstanding_delivery_note(item_code):
return create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company="_Test SDBNB Company",
warehouse="Stores - _TSDBNB",
cost_center="Main - _TSDBNB",
expense_account="Stock Delivered But Not Billed - _TSDBNB",
)

View File

@@ -223,5 +223,17 @@
"doctype": "Company",
"chart_of_accounts": "Standard",
"create_chart_of_accounts_based_on": "Standard Template"
},
{
"abbr": "_TSDBNB",
"company_name": "_Test SDBNB Company",
"country": "India",
"default_currency": "INR",
"doctype": "Company",
"domain": "Manufacturing",
"chart_of_accounts": "Standard",
"default_holiday_list": "_Test Holiday List",
"enable_perpetual_inventory": 1,
"enable_stock_delivered_but_not_billed": 1
}
]
]

View File

@@ -426,6 +426,7 @@ class DeliveryNote(SellingController):
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
"default_expense_account",
"enable_stock_delivered_but_not_billed",
],
as_dict=True,
)
@@ -433,7 +434,7 @@ class DeliveryNote(SellingController):
sdbnb_account = company_values.stock_delivered_but_not_billed
disable_sdbnb_in_sr = company_values.disable_sdbnb_in_sr
default_expense_account = company_values.default_expense_account
is_enabled_sdbnb = company_values.enable_stock_delivered_but_not_billed
for item in self.items:
if item.get("against_sales_invoice"):
if sdbnb_account and item.expense_account == sdbnb_account:
@@ -447,14 +448,16 @@ class DeliveryNote(SellingController):
# Only stock items
if is_stock_item and not item.get("is_fixed_asset") and not item.get("is_subcontracted"):
# Sales Return handling
if self.is_return and disable_sdbnb_in_sr:
if self.is_return and disable_sdbnb_in_sr and sdbnb_account and is_enabled_sdbnb:
if default_expense_account and (
not item.expense_account or item.expense_account == sdbnb_account
):
item.expense_account = default_expense_account
elif sdbnb_account:
elif sdbnb_account and is_enabled_sdbnb:
item.expense_account = sdbnb_account
elif sdbnb_account and item.expense_account == sdbnb_account:
item.expense_account = default_expense_account
if not item.expense_account and default_expense_account:
item.expense_account = default_expense_account

View File

@@ -50,7 +50,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.load_test_records("Stock Entry")
def get_perpetual_defaults(self):
company = frappe.get_doc("Company", "_Test Company with perpetual inventory")
company = frappe.get_doc("Company", "_Test SDBNB Company")
self.perpetual_company = company.name
self.perpetual_account = company.stock_delivered_but_not_billed
self.perpetual_cost_center = company.cost_center

View File

@@ -239,6 +239,7 @@ class Item(Document):
self.validate_item_defaults()
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
self.validate_serialized_change_with_bundle()
self.validate_standard_cost_change()
self.validate_item_tax_net_rate_range()
@@ -1130,6 +1131,25 @@ class Item(Document):
frappe.throw(msg, title=_("Linked with submitted documents"))
def validate_serialized_change_with_bundle(self):
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
if self.is_new() or self.has_serial_no or not self._doc_before_save:
return
# Only relevant when the item was serialized before and is now being unset.
if not self._doc_before_save.has_serial_no:
return
# Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't.
if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}):
frappe.throw(
_(
"Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
).format(frappe.bold(self.name)),
title=_("Serial and Batch Bundle Exists"),
)
def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None:
linked_doctypes = [
"Delivery Note Item",

View File

@@ -1120,6 +1120,47 @@ class TestItem(ERPNextTestSuite):
sabb_qty = frappe.db.get_value("Serial and Batch Bundle", serial_and_batch_bundle, "total_qty")
self.assertEqual(abs(sabb_qty), properties["opening_stock"])
def test_cannot_unset_serialized_while_bundle_exists(self):
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
make_serial_batch_bundle,
)
item = make_item(
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
).name
serial_no = f"{item}-SN-01"
frappe.get_doc(
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
).insert()
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
bundle = make_serial_batch_bundle(
{
"item_code": item,
"warehouse": "_Test Warehouse - _TC",
"company": "_Test Company",
"qty": 1,
"rate": 100,
"voucher_type": "Stock Entry",
"serial_nos": [serial_no],
"type_of_transaction": "Inward",
"do_not_submit": True,
"ignore_sabb_validation": True,
}
)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
self.assertRaises(frappe.ValidationError, doc.save)
# Once the bundle is removed, the item can be made non-serialized.
frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
doc.save()
self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
def set_item_variant_settings(fields):
doc = frappe.get_doc("Item Variant Settings")

View File

@@ -63,6 +63,13 @@ class PurchaseReceiptStockReservation:
return
production_plan_references = self.get_production_plan_references()
if not production_plan_references:
return
reservable_plans = self.get_reservable_production_plans(production_plan_references)
if not reservable_plans:
return
production_plan_items = []
doc.reload()
@@ -70,6 +77,9 @@ class PurchaseReceiptStockReservation:
for row in doc.items:
if row.material_request_item and row.material_request_item in production_plan_references:
_ref = production_plan_references[row.material_request_item]
if _ref.production_plan not in reservable_plans:
continue
docnames.append(_ref.production_plan)
row.update(
{
@@ -95,6 +105,25 @@ class PurchaseReceiptStockReservation:
docnames, from_doctype="Production Plan", to_doctype="Work Order"
)
def get_reservable_production_plans(self, production_plan_references: frappe._dict) -> set:
"""Production Plans that opted into stock reservation (``reserve_stock``).
A Production Plan only gets this flag set if "Auto Reserve Stock" was enabled in
Stock Settings when it was created, or the user ticked "Reserve Stock" manually.
Without this check, a Purchase Receipt would auto-reserve stock for every
Production Plan whenever "Enable Stock Reservation" is on, ignoring both of those.
"""
plan_names = {ref.production_plan for ref in production_plan_references.values()}
return {
p.name
for p in frappe.get_all(
"Production Plan",
filters={"name": ["in", list(plan_names)]},
fields=["name", "reserve_stock"],
)
if p.reserve_stock
}
def get_production_plan_references(self) -> frappe._dict:
production_plan_references = frappe._dict()
material_request_items = []

View File

@@ -511,7 +511,7 @@ def repost_gl_entries(doc):
transactions = directly_dependent_transactions + list(repost_affected_transaction)
# handle stock delivered but not billed ledger entries
if frappe.get_cached_value("Company", doc.company, "stock_delivered_but_not_billed"):
if frappe.get_cached_value("Company", doc.company, "enable_stock_delivered_but_not_billed"):
_update_post_delivery_billed_vouchers(transactions)
enable_separate_reposting_for_gl = frappe.db.get_single_value(

View File

@@ -455,6 +455,7 @@ def get_basic_details(ctx: frappe._dict, item, overwrite_warehouse=True) -> frap
[
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
"enable_stock_delivered_but_not_billed",
],
as_dict=True,
)
@@ -464,6 +465,7 @@ def get_basic_details(ctx: frappe._dict, item, overwrite_warehouse=True) -> frap
and ctx.is_stock_item
and company_values
and company_values.stock_delivered_but_not_billed
and company_values.enable_stock_delivered_but_not_billed
and not ctx.get("is_fixed_asset")
and not ctx.get("is_subcontracted")
):

File diff suppressed because one or more lines are too long

View File

@@ -4,15 +4,18 @@
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.cogs_by_item_group.cogs_by_item_group import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company with perpetual inventory"
class TestCogsByItemGroup(ERPNextTestSuite):
def run_report(self, **extra) -> list:
filters = frappe._dict(
company="_Test Company with perpetual inventory",
company=COMPANY,
from_date="2026-01-01",
to_date="2026-12-31",
)
@@ -20,16 +23,21 @@ class TestCogsByItemGroup(ERPNextTestSuite):
return execute(filters)[1]
def test_cogs_for_item_group(self):
# Reuse the bootstrap item `_Test Item` (item group `_Test Item Group`).
# It has zero stock in `Stores - TCP1`, so this receipt starts from a clean balance.
item = "_Test Item"
# A dedicated item group with a single item keeps `agg_value` scoped to this
# test's COGS. The report sums COGS up the whole item-group tree keyed on the
# company's default expense account, so a shared group would accumulate COGS
# booked by any other test/fixture for the same company within the date range.
# The group name is unique per run so items created by earlier runs (which
# reuse a fixed group name) can't inflate the total either.
item_group = make_item_group(f"_Test COGS Item Group {frappe.generate_hash(length=6)}")
item = make_item(properties={"is_stock_item": 1, "item_group": item_group}).name
make_stock_entry(
item_code=item,
to_warehouse="Stores - TCP1",
qty=10,
rate=100,
company="_Test Company with perpetual inventory",
company=COMPANY,
posting_date="2026-06-01",
)
@@ -40,7 +48,7 @@ class TestCogsByItemGroup(ERPNextTestSuite):
qty=4,
rate=150,
warehouse="Stores - TCP1",
company="_Test Company with perpetual inventory",
company=COMPANY,
update_stock=1,
cost_center="Main - TCP1",
parent_cost_center="Main - TCP1",
@@ -51,7 +59,20 @@ class TestCogsByItemGroup(ERPNextTestSuite):
)
data = self.run_report()
rows = [row for row in data if "_Test Item Group" in row.get("item_group")]
self.assertTrue(rows, "No row found for _Test Item Group")
rows = [row for row in data if item_group in row.get("item_group")]
self.assertTrue(rows, "No row found for the dedicated item group")
# 4 units delivered at 100 valuation rate -> 400 COGS.
self.assertEqual(rows[0].get("cogs_debit"), 400)
def make_item_group(name: str) -> str:
if not frappe.db.exists("Item Group", name):
frappe.get_doc(
{
"doctype": "Item Group",
"item_group_name": name,
"parent_item_group": "All Item Groups",
"is_group": 0,
}
).insert()
return name

View File

@@ -3,6 +3,7 @@
import frappe
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.incorrect_balance_qty_after_transaction.incorrect_balance_qty_after_transaction import (
execute,
@@ -28,6 +29,30 @@ class TestIncorrectBalanceQtyAfterTransaction(ERPNextTestSuite):
flagged = [row for row in data if row.get("item_code") == item]
self.assertEqual(flagged, [])
def test_inconsistent_balance_qty_is_flagged(self):
# a unique item keeps this SLE the only ledger entry for the item/warehouse
item = make_item(properties={"is_stock_item": 1}).name
entry = make_stock_entry(
item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01"
)
# Corrupt the running balance so it no longer matches the cumulative actual_qty --
# exactly the inconsistency this report exists to detect. set_value bypasses the
# ledger recompute that would otherwise keep the two in sync.
sle_name = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": entry.name, "item_code": item, "warehouse": WAREHOUSE},
"name",
)
frappe.db.set_value("Stock Ledger Entry", sle_name, "qty_after_transaction", 5)
flagged = [row for row in self.run_report(item_code=item) if row.get("name") == sle_name]
self.assertEqual(len(flagged), 1, "The tampered stock ledger entry should be flagged")
row = flagged[0]
self.assertEqual(row["expected_balance_qty"], 10) # cumulative actual_qty
self.assertEqual(row["qty_after_transaction"], 5) # tampered balance
self.assertEqual(row["differnce"], 5)
def test_sequence_of_movements_not_flagged(self):
item = "_Test Item 2"
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=20, rate=50, posting_date="2026-06-01")

View File

@@ -4,6 +4,7 @@
import frappe
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.item_wise_consumption.item_wise_consumption import execute
@@ -22,7 +23,9 @@ class TestItemWiseConsumption(ERPNextTestSuite):
return execute(filters)[1]
def test_consumed_vs_delivered_split(self):
item = "_Test Item"
# a uniquely-named item guarantees no residual stock/consumption from other
# tests leaks in -- the report aggregates an item across all warehouses.
item = make_item(properties={"is_stock_item": 1}).name
# purchase receipt gives the supplier mapping and stocks the item
make_purchase_receipt(
item_code=item,
@@ -41,5 +44,7 @@ class TestItemWiseConsumption(ERPNextTestSuite):
self.assertEqual(row[5], 400) # consumed amount
self.assertEqual(row[6], 3) # delivered qty
self.assertEqual(row[7], 300) # delivered amount
self.assertEqual(row[8], 7) # total qty
self.assertEqual(row[8], 7) # total qty = consumed + delivered
self.assertEqual(row[9], 700) # total amount = consumed + delivered amount
self.assertEqual(row[9], row[5] + row[7]) # total aggregates the two amounts
self.assertIn("_Test Supplier", row[10])

View File

@@ -3,28 +3,22 @@
import frappe
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.negative_batch_report.negative_batch_report import execute
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "Stores - _TC"
COMPANY = "_Test Company"
class TestNegativeBatchReport(ERPNextTestSuite):
def run_report(self, item_code):
from erpnext.stock.report.negative_batch_report.negative_batch_report import execute
filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE, "item_code": item_code})
return execute(filters)[1]
return execute(
frappe._dict(
{
"company": "_Test Company",
"warehouse": "Stores - _TC",
"item_code": item_code,
}
)
)[1]
def test_healthy_batch_not_negative(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item = make_item(
def make_batch_item(self):
return make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
@@ -33,22 +27,38 @@ class TestNegativeBatchReport(ERPNextTestSuite):
}
).name
def receive_batch(self, item, qty, posting_date):
"""Receive `qty` of `item`, creating its batch, and return the batch no."""
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=qty, rate=100, posting_date=posting_date)
return frappe.get_all("Batch", filters={"item": item}, pluck="name")[0]
def test_healthy_batch_not_negative(self):
item = self.make_batch_item()
batch = self.receive_batch(item, 10, "2026-06-01")
# issue from the same batch, staying within its balance
make_stock_entry(
item_code=item,
to_warehouse="Stores - _TC",
qty=10,
rate=100,
posting_date="2026-06-01",
)
make_stock_entry(
item_code=item,
from_warehouse="Stores - _TC",
qty=4,
posting_date="2026-06-02",
item_code=item, from_warehouse=WAREHOUSE, qty=4, batch_no=batch, posting_date="2026-06-02"
)
# received 10 then issued 4 -> running batch balance never goes negative
data = self.run_report(item)
self.assertFalse([row for row in data if row.get("batch_no") == batch])
def test_negative_batch_is_flagged(self):
# ERPNext blocks a negative batch balance at submission time (across several
# layers), so a genuinely negative batch only exists as corrupt historical
# data -- which is exactly what this report is meant to surface. Reproduce
# that state directly by forcing the batch's ledger quantity below zero.
item = self.make_batch_item()
batch = self.receive_batch(item, 10, "2026-06-10")
sle = frappe.get_all("Stock Ledger Entry", filters={"item_code": item}, pluck="name")[0]
entry = frappe.get_all("Serial and Batch Entry", filters={"batch_no": batch}, pluck="name")[0]
frappe.db.set_value("Serial and Batch Entry", entry, "qty", -3)
frappe.db.set_value("Stock Ledger Entry", sle, {"actual_qty": -3, "qty_after_transaction": -3})
data = self.run_report(item)
# The batch was only received (10) before being issued (4), so its running
# balance never goes negative; the report must not list this item's batch.
self.assertFalse([row for row in data if row.get("item_code") == item])
flagged = [row for row in data if row.get("batch_no") == batch]
self.assertEqual(len(flagged), 1, "A batch with a negative running balance must be flagged")
self.assertEqual(flagged[0]["qty_after_transaction"], -3)
self.assertEqual(flagged[0]["warehouse"], WAREHOUSE)

View File

@@ -505,7 +505,7 @@ class FIFOSlots:
self._add_serial_fifo_slots(row, fifo_queue, serial_nos)
elif batch_nos and row.get("has_batch_no"):
self._add_batch_fifo_slots(row, fifo_queue, batch_nos)
elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
self._add_to_negative_fifo_head(row, fifo_queue)
else:
fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)])

View File

@@ -1434,6 +1434,47 @@ class TestStockAgeing(ERPNextTestSuite):
self.assertEqual(item_result["total_qty"], -4.0)
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
def test_untagged_receipt_with_negative_batch_head(self):
"""An incoming SLE without batch details must not treat a negative
batch slot at the queue head as a qty slot (TypeError: str += float)."""
sle = [
frappe._dict(
name="Enclosure Item",
actual_qty=-10,
qty_after_transaction=-10,
stock_value_difference=-100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no="QI-06448",
),
frappe._dict(
name="Enclosure Item",
actual_qty=45,
qty_after_transaction=35,
stock_value_difference=1051.65,
warehouse="WH 1",
posting_date="2021-12-05",
voucher_type="Purchase Receipt",
voucher_no="002",
has_serial_no=False,
serial_no=None,
batch_no=None,
serial_and_batch_bundle="SABB-00001294",
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Enclosure Item"]["fifo_queue"]
self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0)
self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0])
self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65])
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
from frappe.utils import add_days, getdate, nowdate

View File

@@ -176,6 +176,10 @@ class SerialBatchBundleService:
parent_details = self.get_parent_details_for_packed_items()
for row in self.doc.get(table_name):
item_code = row.get("rm_item_code") or row.get("item_code")
if not item_code or not self.is_serial_batch_item(item_code):
continue
if (
not via_landed_cost_voucher
and row.serial_and_batch_bundle

View File

@@ -275,10 +275,11 @@ def repost_gate(item_code, warehouse):
racing into a lock-order deadlock. Row locks still enforce correctness; this only cuts the
deadlock/retry churn. Scope is repost-vs-repost only -- the synchronous repost_current_voucher
submit path is deliberately not gated (blocking a submit behind a background repost would be a
worse regression) and keeps relying on the existing deadlock retry. No advisory locks, no gate."""
worse regression) and keeps relying on the existing deadlock retry. Postgres only: MariaDB
keeps the plain deadlock-retry path."""
# hasattr keeps this a graceful opt-in: on an ERPNext predating frappe.db.advisory_lock, fall
# back to no gate rather than raising and marking the Repost Item Valuation permanently Failed.
if frappe.db.db_type in ("postgres", "mariadb") and hasattr(frappe.db, "advisory_lock"):
if frappe.db.db_type == "postgres" and hasattr(frappe.db, "advisory_lock"):
# Tuple key: a colon in item_code/warehouse can't collide two distinct pairs onto one lock.
return frappe.db.advisory_lock(("stock_repost", item_code, warehouse), timeout=REPOST_LOCK_TIMEOUT)
return nullcontext()