Compare commits

...

499 Commits

Author SHA1 Message Date
Mihir Kandoi
1cd32b9c73 Merge pull request #57258 from mihir-kandoi/per-master-company-restriction 2026-07-18 20:39:13 +05:30
Mihir Kandoi
ea5c648ab0 refactor: gate company restrictions per master via Restrict to Companies checkbox
Replaces the Global Defaults toggle. Each Item/Customer/Supplier now
carries a Restrict to Companies checkbox: the Allowed Companies table
only shows (and is mandatory) when checked, is cleared on uncheck, and
permission filtering, read denial and write validation apply only to
masters that have the checkbox set.
2026-07-18 19:03:31 +05:30
Mihir Kandoi
4d49cfa1de Merge pull request #57256 from mihir-kandoi/fix-multi-batch-serial-stock-reco
fix: scope current serial nos to the selected batch in stock reconciliation
2026-07-18 18:56:15 +05:30
Mihir Kandoi
f43f8f75d0 fix: scope current serial nos to the selected batch in stock reconciliation
get_stock_balance_for fetched serial nos across every batch in the
warehouse, so reconciling one batch of a serial+batch item compared the
selected serials against the pool of all batches and failed whenever
multiple batches existed.
2026-07-18 16:51:46 +05:30
Mihir Kandoi
71ccc8885e Merge pull request #57253 from aerele/fix-pick-list-work-order-transferred-qty-leak
fix: exclude transferred_qty from work order item to pick list item m…
2026-07-18 14:05:55 +05:30
pandiyan
5b36f12596 fix: exclude transferred_qty from work order item to pick list item mapping
get_mapped_doc copies same-named fields by default. work order item's
transferred_qty (cumulative across the whole work order) was leaking into
the new pick list item's transferred_qty (meant to track how much of
that pick list row has been converted into a stock entry, starting at 0).

the leaked value then got subtracted again in
get_pending_transfer_stock_qty(), so every pick list after the first
under-transferred raw materials by whatever was already recorded on the
work order, driving material_transferred_for_manufacturing towards zero
across repeated partial pick-list/finish cycles.

fixes #57236, related to #56596
2026-07-18 13:36:48 +05:30
Mihir Kandoi
ca5bec2b77 Merge pull request #57249 from mihir-kandoi/ppmr
fix: add fetch from in production plan material request child table
2026-07-17 22:19:58 +05:30
Mihir Kandoi
dfc2a411e1 fix: add fetch from in production plan material request child table 2026-07-17 22:08:15 +05:30
kaulith
1aee0df79a fix: force-delete repost data file during cleanup (#57245)
* fix(stock): force-delete repost data file during cleanup

* test(stock): cover repost data file cleanup with attach guard
2026-07-17 22:07:20 +05:30
Mihir Kandoi
e0896c656c Merge pull request #57244 from mihir-kandoi/fix-clear-old-logs-orphan-references
fix: clear linked comments, versions and attachments with old logs
2026-07-17 22:06:23 +05:30
Mihir Kandoi
6a69237130 Update erpnext/utilities/__init__.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-17 21:53:16 +05:30
Mihir Kandoi
334346e0f8 Merge pull request #57241 from mihir-kandoi/fix-material-request-buying-price-list
fix: validate buying price list on material request and update item rates on change
2026-07-17 21:38:10 +05:30
Mihir Kandoi
3a63f61832 chore: remove unneccessary flt 2026-07-17 21:24:40 +05:30
Mihir Kandoi
1887825ce5 fix: clear linked comments, versions and attachments with old logs
Repost Item Valuation and BOM Update Log cleared old logs with a raw
delete on the parent table, orphaning timeline comments, versions,
attachments and other reference records.

Fixes #57237
2026-07-17 21:24:08 +05:30
Mihir Kandoi
1ef3cd1d3f fix: dont overwrite rate with 0 if not found 2026-07-17 21:23:49 +05:30
Mihir Kandoi
a31119353c Merge pull request #57223 from aerele/project_validation
fix(projects): include on hold status in project filters and reports
2026-07-17 20:46:11 +05:30
Mihir Kandoi
6dcc0cab3a fix: pass ctx keys get_price_list_rate_for reads, skip rate update on insert
update_item_rates passed price_not_uom_dependent, a key
get_price_list_rate_for never reads, and omitted conversion_factor, so a
stock-UOM price was never converted to the row UOM. The function's
(historically misnamed) price_list_uom_dependant ctx key carries the
Price List's price_not_uom_dependent value: truthy returns the found
rate as-is, falsy multiplies by conversion_factor.

Also guard on_update with is_new(): has_value_changed returns True when
there is no doc_before_save, so every first save re-wrote item rates.
2026-07-17 20:44:07 +05:30
Mihir Kandoi
18b15f2ca9 fix: validate buying price list on material request and update item rates on change 2026-07-17 20:44:07 +05:30
Jatin3128
a33da337ec feat: block sales invoice submit when customer overdue exceeds threshold (#57230)
* feat: block sales invoice submit when customer overdue exceeds threshold

Adds an opt-in, per-customer Overdue Billing Threshold. When enabled in
Accounts Settings, submitting a Sales Invoice is blocked if the customer's
overdue amount exceeds their threshold, unless the current user holds a
configured bypass role. Modeled on the existing credit limit feature.

- Accounts Settings (Credit Limits tab): enable toggle + bypass role.
- Per-customer threshold on the Customer Credit Limit table, shown only
  when the feature is enabled via a property setter (same mechanism as
  subscription / accounting dimension sections). Table relabeled to
  "Credit & Overdue Limits".
- Overdue is read live from the ledger via get_outstanding_invoices
  (payments already netted), summing Sales Invoices past their due date.
- Enforced in Sales Invoice on_submit, after the credit-limit check;
  returns are exempt.
- validate_credit_limit_on_change no longer trips when a row sets only
  the overdue threshold (credit_limit = 0).

Fixes #52960

* fix: compute overdue amount in company currency and format with fmt_money

get_customer_overdue_amount now sums GL Entry debit - credit grouped per
invoice, which is always booked in company currency, instead of using
get_outstanding_invoices which returns the receivable-account currency.
The threshold is in company currency, so the previous comparison could mix
currencies for customers with a foreign-currency receivable account. This
mirrors how get_customer_outstanding computes the figure for the existing
credit-limit check.

The blocking message now formats both amounts with fmt_money using the
company currency.

Adds a test asserting a 100 USD invoice at a conversion rate of 50 is
counted as 5000 in company currency.

* refactor: drop redundant threshold coercion and dead test cleanup

- Coerce the overdue threshold with flt() once when reading it, instead of
  calling flt() on it at each of the three use sites.
- Remove a no-op set_overdue_billing_threshold() call in the feature-disabled
  block (the threshold was already set to that value) and the trailing reset,
  which is dead since each test is rolled back.

No behaviour change.

* fix: compute overdue amount from payment terms, matching the Overdue status

The overdue amount keyed on Sales Invoice.due_date, which set_due_date() sets
to the LAST payment term. An invoice whose first term was past due and unpaid
was therefore counted as zero, even though ERPNext already shows it as Overdue
in the invoice list. The gate and the UI could disagree.

get_customer_overdue_amount now follows the same rule as is_overdue(): per
invoice, the amount that has fallen due (sum of payment schedule terms past
their due date) minus what has been paid, clamped to the outstanding balance.
Invoices without a schedule (POS, opening) still fall back to the invoice due
date, mirroring is_overdue()'s own guard.

The ledger stays the source of truth for what is unpaid: the outstanding per
invoice is still SUM(debit) - SUM(credit) from GL Entry. base_payment_amount is
always stored in company currency, so no currency conversion is needed and the
comparison against the threshold stays consistent.

Adds a test covering a two-term invoice: only the past-due term counts, and
paying it off clears the overdue amount.

* feat: honour the overdue billing threshold set on the customer group

The threshold lives on Customer Credit Limit, which is also rendered on
Customer Group. A threshold set there was stored but never evaluated, so the
configuration was a silent no-op.

get_overdue_billing_threshold now reads the customer's row and falls back to
its customer group, mirroring get_credit_limit. The group's
bypass_credit_limit_check is deliberately not consulted: it is labelled for the
credit limit check at sales order and is unrelated to overdue billing.

get_customer_group_details also dropped the threshold when copying group rows
onto a customer, because it copied a single hardcoded field per table. It now
copies a list of fields per table, so credit_limit and overdue_billing_threshold
both carry over.
2026-07-17 18:58:10 +05:30
rohitwaghchaure
40f861c0a0 fix: parallel reposting stalls between scheduler ticks (#57220) 2026-07-17 16:27:27 +05:30
Poovetha
7248961568 fix(projects): add project filter 2026-07-17 15:03:25 +05:30
Poovetha
79e5ccd370 test(projects): add test to ensure on hold project retains status 2026-07-17 15:03:25 +05:30
Poovetha
51a9fc0316 fix(projects): include on hold status in project filters and reports 2026-07-17 15:03:25 +05:30
Mihir Kandoi
3f53475cad Merge pull request #57233 from mihir-kandoi/fix-stock-entry-pending-work-order-query
fix: replace column-literal work order filter with server-side query
2026-07-17 14:05:05 +05:30
Mihir Kandoi
eed7c98b30 fix: replace column-literal work order filter with server-side query
The work_order link filter in Stock Entry passed the string
`tabWork Order`.produced_qty as a filter value. It was never a real
column comparison: db_query coerces string values on numeric fields
with flt(), so the condition silently degraded to qty > 0, and on
backends that don't coerce text to numeric (postgres) such filters
fail with InvalidTextRepresentation.

Move the condition into a whitelisted search query that compares the
columns properly (qty > produced_qty), mirroring pick_list's
get_pending_work_orders.
2026-07-17 13:54:08 +05:30
Mihir Kandoi
d8eb029284 Merge pull request #57228 from mihir-kandoi/fix-transit-warehouse-company-fallback
fix: fall back to the company in-transit warehouse
2026-07-17 13:06:54 +05:30
Mihir Kandoi
920e64ded0 fix: fall back to the company in-transit warehouse
set_transit_warehouse re-tested from_warehouse inside a guard that
already requires it, so the Company branch of its ternary was
unreachable: with no default on the source warehouse the field stayed
empty and Company.default_in_transit_warehouse was silently ignored.
Try the warehouse's default first, then the company's.
2026-07-17 13:05:29 +05:30
Diptanil Saha
9fdb6dd875 Merge pull request #57224 from diptanilsaha/fix/dunning_type_validation
fix: added missing validations for `Dunning Type`
2026-07-17 10:45:40 +05:30
diptanilsaha
2325068b19 test: added tests for Dunning Type validation 2026-07-17 10:35:10 +05:30
diptanilsaha
c8e7674d63 fix: added missing validations for Dunning Type 2026-07-17 10:35:04 +05:30
Mihir Kandoi
0cba26c608 Merge pull request #57225 from mihir-kandoi/fix-duplicate-workspace-links
fix: remove duplicate links from home and projects workspaces
2026-07-17 08:05:53 +05:30
Mihir Kandoi
7fc43aba72 fix: remove duplicate links from home and projects workspaces
Workspace re-export in #56864 duplicated every link in the Home and
Projects workspaces, so desk renders each link twice. Same issue as
55afd95b20. Bumped modified so existing sites re-sync.
2026-07-17 07:55:17 +05:30
Mihir Kandoi
fc50e23daa Merge pull request #57194 from aerele/fix-operation-batch-size-flag-n-plus-one
fix: batch operation batch-size flag lookups to avoid n+1 query in wo…
2026-07-17 07:47:59 +05:30
pandiyan
6500752440 fix: batch operation batch-size flag lookups to avoid n+1 query in work order operations 2026-07-16 23:35:45 +05:30
MochaMind
2b4fc02c81 fix: sync translations from crowdin (#57188) 2026-07-16 18:01:04 +02:00
Shllokkk
76298d9bee Merge pull request #57198 from Shllokkk/strip-account-number-coa-importer
fix: strip account number when building account name in COA importer
2026-07-16 20:50:58 +05:30
Nishka Gosalia
5fc03a116a feat: map settings for DocTypes to show on settings dialog (#57025)
fix: mapping settings for DocType settings
2026-07-16 19:30:27 +05:30
Diptanil Saha
ac68db3fa6 refactor(dunning): converted get_dunning_letter_text to doc method and restrict_globals on render_template (#57205) 2026-07-16 16:06:13 +05:30
rohitwaghchaure
7b517a4e64 feat: book Expenses Added To Stock GL entries for stock vouchers (configurable) (#57190)
* feat: book Expenses Added To Stock GL entries for Stock Entry, Stock Reconciliation and LCV

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: make stock expense GL booking configurable via Accounts Settings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: skip stock expense booking for unconfigured companies, check flag once per compose

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:04:45 +00:00
Mihir Kandoi
a25acee43f Merge pull request #57179 from aerele/link-portal-users-to-contact-from-relevent-doctype
feat(stock): automatically link portal users to their associated contact profiles for customers and suppliers
2026-07-16 15:23:49 +05:30
Afsal Syed
9ae2069bd9 test(stock): add portal user contact link verification for customer and supplier 2026-07-16 15:11:28 +05:30
Afsal Syed
337a06dfb6 feat(stock): automatically link portal users to their associated contact profiles for customers and suppliers 2026-07-16 15:11:28 +05:30
Mihir Kandoi
103f3e50a7 Merge pull request #57202 from mihir-kandoi/pg-read-committed-gates
fix(stock): serialize postgres stock writes per (item, warehouse); block GL inserts during account rename
2026-07-16 14:10:56 +05:30
Mihir Kandoi
d0010fda68 Merge pull request #57204 from mihir-kandoi/fix-production-plan-min-order-qty
fix: consider min order qty in the purchase/transfer flow of production plan
2026-07-16 13:28:55 +05:30
Mihir Kandoi
448316fe8e test: assert row count in the min order qty split scenario 2026-07-16 12:56:11 +05:30
Mihir Kandoi
2f8d588093 fix: consider min order qty in the purchase/transfer flow of production plan
The transfer flow ignored Consider Minimum Order Qty twice: the JS
handler force-reset the checkbox before fetching items, and the
purchase remainder left after allocating transfers from other
warehouses was never raised to min_order_qty (the check runs on the
total requirement before the split).

Drop the JS reset and apply min order qty to the purchase remainder,
in stock UOM before the purchase UOM conversion.
2026-07-16 12:46:21 +05:30
Mihir Kandoi
b100e6d414 fix(stock): serialize pick list allocation per item on postgres
Two simultaneous allocations for the same item can both claim the same stock
on postgres: the picked-items locking read cannot see the rows another
in-flight creation is inserting, while MariaDB's gap locks make the creations
take turns. Advisory-gate set_item_locations per item (sorted against
deadlocks) so the second allocation waits, then subtracts the first's claim.
MariaDB unchanged.
2026-07-16 09:58:09 +05:30
Mihir Kandoi
897eca895a fix(stock): fall back gracefully when transaction_advisory_lock is unavailable
Same hasattr pattern as repost_gate: an ERPNext ahead of its frappe build keeps
the status-quo serialization-failure retries instead of failing every stock
submission on postgres.
2026-07-16 09:32:03 +05:30
Mihir Kandoi
35a9d7b09c fix(accounts): block GL Entry inserts during account rename on postgres
The for_update read in _ensure_idle_system only blocks new GL inserts on
MariaDB, via the gap lock it takes; a postgres row lock never blocks inserts,
so the guard silently degraded to the 5-minute recency check. LOCK TABLE IN
EXCLUSIVE MODE blocks writers (not readers) until the rename commits and NOWAIT
keeps the wait=False fail-fast, feeding the existing QueryTimeoutError path.
2026-07-16 09:21:17 +05:30
Mihir Kandoi
9cfdb482fc fix(stock): serialize stock writes per (item, warehouse) with a txn advisory lock on postgres
Postgres locking reads never see rows a concurrent transaction is inserting
(MariaDB's gap locks block the insert, then its locking reads return the fresh
row), so two concurrent writers for the same (item, warehouse) compute from the
same stale previous SLE and the loser overwrites Bin with a wrong absolute qty.
Today only the REPEATABLE READ serialization-failure retry catches this; the
gate makes correctness lock-based, covers the empty-history first-transaction
case (nothing exists to row-lock), and keeps negative-stock validation accurate
against concurrently inserted SLEs. Taken at the top of make_sl_entries (sorted
pairs, before the future_sle_exists cache warms) and in
update_entries_after.__init__ for the repost paths; re-entrant, released at
commit. MariaDB paths unchanged.
2026-07-16 09:21:07 +05:30
Shllokkk
6f6ec3072a Merge branch 'develop' into strip-account-number-coa-importer 2026-07-16 00:18:46 +05:30
Shllokkk
cbe406ee2a fix: strip account number when building account name in COA importer 2026-07-16 00:17:35 +05:30
Mihir Kandoi
7fe4dc1367 Merge pull request #56746 from frappe/mergify/configuration-deprecated-update
ci(mergify): upgrade configuration to current format
2026-07-15 12:53:07 +05:30
Mihir Kandoi
d5126dcad5 Merge pull request #56884 from aerele/fix/v16-report-date-guard
fix: validate mandatory date filters in reports
2026-07-15 12:51:29 +05:30
Diptanil Saha
fee3a6e0fd fix(accounts): update AU standard chart of accounts (#57145)
Co-authored-by: Jebajebas <jeba.j@arus.co.in>
2026-07-15 12:22:02 +05:30
Diptanil Saha
72b72a81fa fix(project): improved access control for project users (#56675)
* fix: permission check for `get_task_html` and `get_timesheet_html`

* fix(project): enabled project access control for users without `Projects User` Role

* fix(portal): validate user permissions for project portal

* fix: patch to add docshare for the project users

* fix(patch): selecting correct column on the query

* fix(project): grant access to all the current users for new project

* fix(portal): fixed condition to display timesheets on project

* test(portal): add access control tests for project user

* fix(project): using `frappe.has_permission` instead of `self.has_permission` to validate user permissions

* fix(project): granting docshare access for every ProjectUser

Roles for an User can be removed any time or an User Permission can be added which might restrict the access to the Project.

* fix(patch): create docshare documents for non-cancelled projects and users who have no docshare documents

* test(project): removed `test_control_access_does_not_touch_users_with_real_permission`
2026-07-15 12:20:27 +05:30
rohitwaghchaure
e99966a38e fix: skip redundant reposting of dependent items (#57092)
* fix: skip redundant reposting of dependent items

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: use earliest cascade datetime and batch repost item lookup

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:09:34 +05:30
Mihir Kandoi
1d6edf9674 fix: name every conflicting voucher in the reserved batch error (#57174)
* fix: name every conflicting voucher in the reserved batch error

* fix: exclude fully-delivered reservations from the conflict message

* fix: round outstanding qty guard consistently with the conflict gate
2026-07-15 06:13:32 +00:00
Mihir Kandoi
67587a79e9 Merge pull request #57169 from mihir-kandoi/fix-shared-reserved-batch-delivery
fix: allow delivery when a batch is reserved across multiple sales orders
2026-07-15 11:10:58 +05:30
Mihir Kandoi
2310c4c005 fix: allow delivery when a batch is reserved across multiple sales orders
validate_reserved_batches compared the voucher's own qty against the
remaining batch qty, so delivering one order's reserved unit threw
Reserved Batch Conflict whenever the remainder exactly matched another
order's reservation. Compare the remaining batch qty against the
aggregated outstanding reserved qty (qty - delivered_qty) of other
vouchers instead, excluding reservations the voucher itself delivers.
2026-07-15 10:59:52 +05:30
Mihir Kandoi
576a5d26df Merge pull request #57137 from aerele/projects_status
feat: add on hold status to project
2026-07-15 10:51:28 +05:30
Mihir Kandoi
b6b19790d2 Merge pull request #57164 from mihir-kandoi/gh57158
fix: set correct currency in supplier quotation net rate field
2026-07-15 10:46:32 +05:30
Mihir Kandoi
117bb912cb Merge pull request #57163 from mihir-kandoi/hide-job-card-poi
fix: hide job card field in purchase order item
2026-07-15 10:42:58 +05:30
Mihir Kandoi
27672851cd fix: set correct currency in supplier quotation net rate field 2026-07-15 10:34:07 +05:30
Mihir Kandoi
f44bcae47d fix: hide job card field in purchase order item 2026-07-15 10:31:07 +05:30
Mihir Kandoi
71f843e5bf Merge pull request #57154 from aerele/fix-production-plan-bom-warehouse-n-plus-one
fix: batch BOM source warehouse lookup in get_production_items to avo…
2026-07-14 22:18:58 +05:30
pandiyan
4705909cee fix: batch BOM source warehouse lookups to avoid n+1 queries in production plan work order creation 2026-07-14 22:07:52 +05:30
Nabin Hait
04f75cc64f Merge pull request #56983 from nabinhait/fix-flaky-usd-exchange-rate-tests
test: seed current-dated USD↔INR exchange rate to fix flaky currency tests
2026-07-14 18:13:28 +05:30
Nabin Hait
5133ba47b7 fix: make currency exchange truly idempotent against any pre-existing state
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-14 18:02:59 +05:30
rohitwaghchaure
1fd2faa68d fix: permission issue (#57112) 2026-07-14 12:22:48 +00:00
Soham Kulkarni
4d2b603ba6 Merge pull request #57134 from sokumon/merge-workspaces
fix: merge erpnext workspaces
2026-07-14 17:10:28 +05:30
Poovitha Palanivelu
672fadaa78 feat: add on hold status to project 2026-07-14 16:43:32 +05:30
Mihir Kandoi
d7f4524cdd refactor: convert Hide Currency Symbol in Global Defaults to a Check field (#57135) 2026-07-14 11:10:07 +00:00
Mihir Kandoi
876adcb535 Merge pull request #57129 from SandraFrappe/fix/purchase-order-duplicate-material-request-item
fix: prevent duplicate material request items in purchase order
2026-07-14 16:31:26 +05:30
Mihir Kandoi
b2ec906ff3 test: remove test 2026-07-14 16:18:53 +05:30
sokumon
f2e8c7b664 fix: add sequence for erpnext 2026-07-14 16:05:44 +05:30
Mihir Kandoi
b6cce627a8 feat: company-wise restriction for Item, Customer and Supplier masters (#57124) 2026-07-14 10:30:47 +00:00
SandraFrappe
2d6f89a7f5 fix: prevent duplicate material request items in purchase order 2026-07-14 14:32:06 +05:30
sokumon
ac99d28100 chore: merge erpnext workspaces 2026-07-14 14:27:39 +05:30
Mihir Kandoi
823dbb7a2b Merge pull request #57127 from mihir-kandoi/naming-posting-date-default-on
feat: make naming series based on posting datetime on by default on n…
2026-07-14 13:49:34 +05:30
Mihir Kandoi
7db93d8b19 feat: make naming series based on posting datetime on by default on new sites 2026-07-14 13:38:41 +05:30
Diptanil Saha
d82e6c4f12 fix(accounts): added permission checks on get_account_balances_coa (#57107) 2026-07-14 07:26:43 +00:00
Smit Vora
7f4be47d43 Merge pull request #56912 from ljain112/refactor-subcon
refactor: move functionality in postprocess for mapped doc
2026-07-14 11:53:58 +05:30
Khushi Rawat
7371df129f Merge pull request #57111 from khushi8112/remove-dead-dashboard-fixtures
chore: remove dead assets dashboard_fixtures with broken imports
2026-07-14 11:53:07 +05:30
khushi8112
14a15cc6f9 chore: remove dead assets dashboard_fixtures with broken imports (#57079)
erpnext.accounts.dashboard_fixtures and erpnext.buying.dashboard_fixtures
were removed in 2020 when dashboards were exported to JSON fixtures.
The assets module's dashboard_fixtures.py was left behind unreferenced;
its dashboard, charts and number cards already exist as exported JSON.
2026-07-14 11:40:34 +05:30
Mihir Kandoi
f4f9030437 Merge pull request #57114 from frappe/mergify/bp/develop/pr-57113
fix(stock): set stock_uom on transferred Stock Reservation Entries (backport #57113)
2026-07-14 11:39:00 +05:30
Mihir Kandoi
1d5ec14530 Merge pull request #57116 from aerele/get_bundle_wise_serial_nos-db-params-limit
fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos
2026-07-14 11:29:34 +05:30
PranavDarade
e321e95e59 fix(stock): set stock_uom on transferred Stock Reservation Entries
StockReservation.transfer_reservation_entries_to() created the transferred SREs without copying stock_uom, in both the entries_to_reserve dict and the extra-items fallback. get_items_to_reserve() already selects the item's stock_uom, so entry.stock_uom is used.

On sites with a global default stock_uom (e.g. "Nos"), frappe's _set_defaults() backfilled the blank field, so the transfer silently stored the wrong UOM for any item whose stock UOM is not the default. On sites without that default the SRE's validate_mandatory() raised "Stock UOM is required", aborting Work Order submission for the Subcontracting Inward Order / Production Plan flows.

(cherry picked from commit 5991ecfa3d)
2026-07-14 11:27:07 +05:30
S Sakthivel Murugan
3e8784f596 fix: validate mandatory date filters in reports 2026-07-14 11:24:51 +05:30
Afsal Syed
e748bf512b test(stock): add unit test for get_bundle_wise_serial_nos query 2026-07-14 11:18:17 +05:30
Afsal Syed
4544a6c935 fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos 2026-07-14 11:18:17 +05:30
Mihir Kandoi
336970aa5a Merge pull request #57115 from mihir-kandoi/fix/supplier-scorecard-month-end-idempotency
fix: duplicate scorecard period when supplier is created on a month end
2026-07-14 10:55:59 +05:30
Mihir Kandoi
3aafda331b fix: duplicate scorecard period when supplier is created on a month end
make_all_scorecards' dedup query used strict bounds, so a single-day
period (supplier created on a month's last day -> start == end under
"Per Month") never matched its own window and was re-created on every
call. The daily refresh_scorecards job would insert a duplicate
submitted period each day for such suppliers, and
test_make_all_scorecards_is_idempotent fails on any date where
nowdate() - 75 days lands on a month end — both nightly server suites
went red on 2026-07-14 (75 days after April 30).

Inclusive bounds cannot false-match adjacent periods: each next period
starts at end_date + 1, so closed intervals never touch.
2026-07-14 10:20:41 +05:30
Mihir Kandoi
39b5c6ba2a Merge pull request #57101 from aerele/fix/job-card-work-order-transferred-qty
fix(manufacturing): preserve job card transferred quantity
2026-07-13 21:06:28 +05:30
Mihir Kandoi
3dba2d23b8 Merge pull request #57099 from aerele/fix-stock-entry-bom-query-redundancy
perf: avoid redundant bom cost_allocation_per query per finished item…
2026-07-13 21:05:13 +05:30
Mihir Kandoi
b23224b3d6 Merge pull request #57091 from aerele/fix/so-qty-company-warehouse
fix(stock): show qty (company) and qty (warehouse) in sales transactions
2026-07-13 21:03:07 +05:30
Mihir Kandoi
c0147e5a2e Merge pull request #57089 from aerele/fix-pick-list-barcode-scan-max-qty
fix: allow barcode scan to add and increment items in pick list
2026-07-13 21:02:08 +05:30
Sudharsanan11
51f9b70bfa test(manufacturing): cover transferred quantity across job cards 2026-07-13 19:40:20 +05:30
Sudharsanan11
951e432a1b fix(manufacturing): preserve job card transferred quantity 2026-07-13 19:40:20 +05:30
pandiyan
feb750f082 perf: avoid redundant bom cost_allocation_per query per finished item row in stock entry 2026-07-13 18:29:23 +05:30
Diptanil Saha
9c353741ad fix(tnc): using get_cached_doc to retrieve template for get_terms_and_conditions and permission checks (#57096) 2026-07-13 11:24:56 +00:00
Sudharsanan11
4e5e1f6596 test(stock): assert qty (company) and qty (warehouse) on item details
covers sales order, quotation, sales invoice, delivery note and
purchase order, asserting actual_qty from the row warehouse and
company_total_stock across all warehouses of the company.
2026-07-13 14:37:23 +05:30
Sudharsanan11
ab30bab6cb fix(stock): show qty (company) and qty (warehouse) in sales transactions
company was passed to get_bin_details only for purchase order, so
company_total_stock was never returned for sales order, quotation,
sales invoice and delivery note and the qty (company) column always
read zero. pass ctx.company for every doctype, which also drops the
dependency on doc being supplied.

on the client, set_actual_qty copied only actual_qty out of the
response, so qty (company) never refreshed on a warehouse change. use
frm.call with child so every bin field is applied, pass
include_child_warehouses to match the server, and include quotation.
2026-07-13 14:37:23 +05:30
pandiyan
3ece4a615d fix: allow barcode scan to add and increment items in pick list
- allow new rows on scan when pick manually is enabled, since only
  then are scanned rows not subject to being overridden by
  set_item_locations on save
- stop capping picked qty at the default demand qty (1) for rows
  added by the scanner itself, so repeat scans of the same barcode
  keep incrementing the row instead of failing with "maximum
  quantity scanned"
- ignore barcode uom when matching an existing row if new rows
  aren't allowed, since there's no alternate-uom row to fall back to
2026-07-13 13:54:54 +05:30
ruthra kumar
33abc53d7a Merge pull request #56817 from Soham-ambibuzz/philipinnes_localization_coa_v3
feat: restructure Philippines chart of accounts with amortization sup…
2026-07-13 12:56:31 +05:30
ruthra kumar
47341ff55c Merge pull request #56628 from aerele/multi-currency
fix(journal-entry): fetch outstanding on foreign currency
2026-07-13 12:35:15 +05:30
ruthra kumar
7144e5b29a Merge pull request #56458 from Shllokkk/psoa-restrict-jinja-globals
fix: restrict jinja globals in process statement of accounts templates
2026-07-13 12:34:04 +05:30
ruthra kumar
f0326aa6cf Merge pull request #56902 from frappe/fix-budget-variance-chart-month-shift
fix(budget-variance): correct month shift in comparison chart
2026-07-13 12:31:27 +05:30
Khushi Rawat
2580601937 Merge pull request #57071 from iamejaaz/fix-letterhead-company-guard
fix: guard company logo lookup in default letterheads
2026-07-13 11:58:31 +05:30
Khushi Rawat
14dac6ecc4 Merge pull request #55276 from aerele/fix/asset-repair-fully-depreciated
fix(asset): allow asset repair creation for fully depreciated assets
2026-07-13 02:09:13 +05:30
Mihir Kandoi
e8a61ecfbb Merge pull request #57073 from mihir-kandoi/gh57072
fix: make represents company field in purchase invoice ignore user pe…
2026-07-13 00:08:24 +05:30
Mihir Kandoi
6729a53fee fix: make represents company field in purchase invoice ignore user permissions 2026-07-12 23:56:23 +05:30
MochaMind
612a3f20d8 chore: update POT file (#57067) 2026-07-12 20:25:54 +02:00
Ejaaz Khan
e39ca72997 fix: set explicit table and logo widths in grey letterhead 2026-07-12 22:03:32 +05:30
Ejaaz Khan
23c09fe0f3 fix: guard company logo lookup in default letterheads 2026-07-12 21:17:42 +05:30
Nabin Hait
e1e56b6920 test: adjust currency tests for deterministic seeded exchange rate
Seeding a current-dated USD->INR rate makes get_exchange_rate resolve
62.9 on today() instead of hitting the live API, which exposed three
tests that implicitly relied on a different/undefined current rate:

- customer: dropped its own colliding current-dated seed (ignored via
  ignore_if_duplicate, and its cleanup deleted the shared seed) and now
  asserts the quotation resolves the seeded rate via get_exchange_rate.
- exchange_rate_revaluation: the revalued rate (62.9) is now below the
  booked 80, so the revaluation is a loss (debited) rather than a gain;
  derive the gain/loss column from the sign instead of assuming a gain.
- purchase_invoice: the receipt rate was an accidental tuple (70,) that
  got discarded and recomputed to the seed; set explicit rates with the
  receipt above the invoice so the stock exchange difference is a credit,
  matching the asserted column.
2026-07-12 21:12:29 +05:30
Nabin Hait
7928608b0f Merge pull request #56973 from nabinhait/refactor-stock-gl-composer
refactor(stock): de-conditionalize BaseStockGLComposer via subclass hooks
2026-07-12 20:32:49 +05:30
Mihir Kandoi
4a160c10f8 Merge pull request #57070 from mihir-kandoi/fix-dimension-search-check-field
fix: accounting dimension search matching unrelated records
2026-07-12 20:26:26 +05:30
Mihir Kandoi
38e8c3b1fe fix: accounting dimension search matching unrelated records
#56871 routed any searchfield without a DocField meta — including
"name", which get_search_fields() always appends — into the
`= cint(txt)` branch. For non-numeric search text cint() yields 0, so
`is_group = 0` (Cost Center) matched every leaf record on both
engines, and `name = 0` matched every non-numeric name on MariaDB.

Skip Check fields from or_filters entirely — a checkbox can't match
search text — and keep LIKE for everything else, including "name".
2026-07-12 18:37:51 +05:30
Mihir Kandoi
8db4f82af5 Merge pull request #57069 from mihir-kandoi/fix-job-card-pick-list-project
fix(stock): propagate project from job card to stock entry
2026-07-12 17:51:26 +05:30
Mihir Kandoi
72492f5da2 fix(stock): propagate project from job card to stock entry 2026-07-12 17:32:30 +05:30
mergify[bot]
45102e12cc feat: explain FIFO allocation of fixed Discount Amount on Sales Order (backport #56436) (#57062)
* feat: explain FIFO allocation of fixed Discount Amount on Sales Order (#56436)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
(cherry picked from commit 62fed1d562)

# Conflicts:
#	erpnext/selling/doctype/sales_order/sales_order.json

* chore: resolved conflicts

---------

Co-authored-by: Mohammad Umair Sayed <umair_sayyed@yahoo.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-11 17:57:49 +00:00
Raghav Ruia
b96d6e2a93 fix: remove incorrect Payable account_type from Customer Deposits in Philippines CoA (#57018) 2026-07-11 23:09:14 +05:30
Pandiyan P
ad17efe243 fix(accounts): retain invoice table on opening invoice creation error (#56353)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-07-11 13:17:28 +00:00
Pandiyan P
0524a235d7 fix: update events order by date asc (#56963)
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
2026-07-11 18:24:49 +05:30
Nikhil Kothari
d449ad3b3f fix(banking): allow negative balance in bank statement import (#56959) 2026-07-11 12:28:03 +00:00
Vishnu Priya Baskaran
9b66382463 fix(financial_statement): render columnar financial statements instea… (#56921) 2026-07-11 16:09:26 +05:30
Vishnu Priya Baskaran
a721ad3948 fix(payment reconciliation): read user permissions from current session user (#56782) 2026-07-11 16:08:27 +05:30
Vishnu Priya Baskaran
199eeff22c fix: map stock_qty in apply_price_list_on_item (#56869) 2026-07-11 15:03:49 +05:30
Nabin Hait
c5bc17b884 Merge branch 'develop' into fix-flaky-usd-exchange-rate-tests 2026-07-11 14:29:22 +05:30
Nabin Hait
df60d61402 Merge branch 'develop' into refactor-stock-gl-composer 2026-07-11 14:28:52 +05:30
Nabin Hait
0264202b42 Merge pull request #56978 from nabinhait/refactor-ac-services
refactor(accounts): extract deferred-accounting and document-schedule from AccountsController
2026-07-11 14:28:16 +05:30
MochaMind
4b6860de62 fix: sync translations from crowdin (#57010)
* fix: Swedish translations

* fix: Bosnian translations
2026-07-11 00:33:45 +02:00
Shllokkk
3cabf13abe Merge pull request #57045 from Shllokkk/work-order-mapper-method-path
fix: use correct mapper path for make_work_orders call
2026-07-10 21:01:06 +05:30
Shllokkk
394c9d80f9 fix: use correct mapper path for make_work_orders call 2026-07-10 20:59:08 +05:30
Mihir Kandoi
ad9430d17e Merge pull request #57035 from aerele/perf-bom-default-accounts-lookup
perf(bom): batch default account/cost-center/warehouse lookups in bom…
2026-07-10 17:15:01 +05:30
Vishnu Priya Baskaran
a30f72dae1 fix: fetch payment entry reference amounts from invoice (#56928) 2026-07-10 17:01:49 +05:30
Mihir Kandoi
51d7bf64a8 Merge pull request #57031 from mihir-kandoi/fix-job-card-pick-list-transfer
fix(stock): link job card in stock entry created from pick list
2026-07-10 16:54:31 +05:30
Mihir Kandoi
bf4e1a3ae0 Merge pull request #57033 from aerele/perf-bom-raw-material-warehouse-lookup
perf(stock): avoid n+1 queries for work order item source warehouse
2026-07-10 16:37:44 +05:30
Mihir Kandoi
2c03894e00 fix(stock): batch job card item lookup and honour semi_fg_bom
Fetch job_card_item for all pick list locations in one query instead
of one per row, and prefer the job card's semi_fg_bom over the work
order BOM, mirroring the direct Job Card -> Stock Entry mapper.
2026-07-10 16:36:44 +05:30
pandiyan
e88e63976a perf(bom): batch default account/cost-center/warehouse lookups in bom explosion 2026-07-10 16:35:43 +05:30
pandiyan
3e4d5e6745 perf(stock): avoid n+1 queries for work order item source warehouse
hoist the invariant work order lookup and batch-fetch work order item
source warehouses once instead of querying per raw material row in
get_bom_raw_materials
2026-07-10 16:03:18 +05:30
Mihir Kandoi
bdbb8481b0 fix(stock): link job card in stock entry created from pick list
A Stock Entry created from a Pick List against a job card's Material
Request never set job_card, job_card_item, fg_completed_qty or the
'Material Transfer for Manufacture' purpose, so the Job Card did not
recognize the transfer and blocked submission. The WIP warehouse was
also not populated.

Route such pick lists through a job-card-aware branch mirroring the
direct Material Request -> Stock Entry mapper, and set the purpose to
'Material Transfer for Manufacture' in the work order branch so the
WO -> MR -> Pick List flow updates the work order too.
2026-07-10 15:20:28 +05:30
ruthra kumar
4aabe3347f Merge pull request #56801 from ruthra-kumar/reversing_exchange_rate_revaluation
refactor: reversing exchange rate revaluation journals
2026-07-10 11:49:41 +05:30
Sudharsanan Ashok
9cb6610b9e fix(stock): correct stock ageing value for moving average and lifo items (#56693)
* fix(stock): recompute moving average item slots

* test(stock): add test to validate the stock value of moving average items

* fix(stock): support lifo valuation in stock ageing report

lifo items were aged as fifo (oldest consumed first), so the report kept the
newest lots on hand and reported the wrong stock value and average age. prefetch
each item's valuation method (it can't be resolved mid-stream without breaking the
unbuffered cursor) and consume from the tail for lifo items. also reuse that shared
lookup in the moving average revaluation pass. scoped to plain items; batch, serial
and same-voucher repack legs stay on fifo.

* test(stock): add test for lifo consumption in stock ageing report
2026-07-10 11:34:47 +05:30
ruthra kumar
65775e59a1 refactor(test): for reverse journals as well 2026-07-10 11:34:30 +05:30
ruthra kumar
6838242063 refactor: handle reverse ERR journals in AR / AP report 2026-07-10 10:38:54 +05:30
ruthra kumar
a0b14c0607 refactor: reversal capability on exchange rate revaluation 2026-07-10 10:38:52 +05:30
ruthra kumar
6a4c5b6062 refactor: add payment ledger to ignore link 2026-07-10 08:27:28 +05:30
Vishnu Priya Baskaran
38da3fc76e fix: display outstanding amount using company default currency (#56785)
Co-authored-by: S Sakthivel Murugan <s.sakthivelmurugan2003@gmail.com>
2026-07-10 00:57:20 +05:30
Mihir Kandoi
271f8cf1ca Merge pull request #56948 from aerele/feat/production-plan-group-rm-warehouse
feat(manufacturing): allow group warehouse for raw material availability in production plan
2026-07-09 20:52:18 +05:30
Mihir Kandoi
1eed00f5d4 Merge pull request #56428 from raghavisruia/item-allow-negative-stock-confirmation
feat: confirmation dialog when enabling negative stock on Item
2026-07-09 20:52:03 +05:30
Mihir Kandoi
4fbb20fbd9 Merge pull request #56255 from akhtarmohsin/fix-variant-of-filter-item-list
Fix Variant Of filter to show only template items
2026-07-09 20:41:54 +05:30
Mihir Kandoi
8583f0ba05 Merge pull request #56979 from harisansari008/fix/bom-routing-update-operations
fix: update BOM operations when routing is changed
2026-07-09 20:41:12 +05:30
Mihir Kandoi
1b6d3ee870 Merge pull request #56561 from aerele/fix/report-currency
fix: use company currency instead of global default in report
2026-07-09 20:38:55 +05:30
Sudharsanan11
2247ef9a50 test(manufacturing): add production plan group warehouse tests
verify child-stock aggregation, transfer sourcing from child warehouses,
that a for warehouse outside the group is rejected, and that a for
warehouse is required when raw materials are fetched.
2026-07-09 20:37:04 +05:30
Sudharsanan11
0574a0d95e feat(manufacturing): allow group warehouse for raw material availability in production plan
add an optional raw material group warehouse on production plan. when set,
raw material availability is checked across its child warehouses (bin rows
aggregated), while material is still received into for warehouse. for
warehouse is restricted to a child of the group and required when raw
materials are fetched; a group warehouse can never reach a material request
line. when the group is left blank, availability falls back to for warehouse
and the previous flow.
2026-07-09 20:37:04 +05:30
Sudharsanan11
b625525b03 fix(manufacturing): accept plain dict for doc in get_items_for_material_requests
the whitelisted endpoint typed doc as str | frappe._dict | Document, so a
json request body (a plain dict) failed pydantic type validation. widen to
str | dict | Document, matching the convention used elsewhere in erpnext.
2026-07-09 20:37:04 +05:30
Mihir Kandoi
87e7e53a6a Merge pull request #56932 from aerele/fix/work-order-planned-date-validation
fix: validate planned end date is not before planned start date in wo…
2026-07-09 20:34:57 +05:30
Mihir Kandoi
97533812e7 Merge pull request #56925 from aerele/fix/batch-bin-lookup-delivery-note
perf: batch bin lookups in delivery note stock update
2026-07-09 20:34:37 +05:30
Mihir Kandoi
508f03a73e Merge pull request #56923 from aerele/fix/reorder-item-warehouse-lookup-perf
perf: avoid per-row Warehouse doc fetches in auto reorder job
2026-07-09 20:33:56 +05:30
Mihir Kandoi
8dd56ecba5 Merge pull request #56913 from aerele/fix/trend-report-column-translations
fix: make trend report based-on and group-by column labels translatable
2026-07-09 20:33:13 +05:30
Mihir Kandoi
d6682703fb Merge pull request #56909 from aerele/fix/sync-variant-item-code-on-abbr-rename
fix(stock): rename variant item_code/item_name when attribute abbreviation changes
2026-07-09 20:32:50 +05:30
Mihir Kandoi
015fb61b10 Merge pull request #56984 from aerele/fix/apply-price-list-doc-dict
fix(stock): repair client calls broken by type-hint and module refactors
2026-07-09 20:31:24 +05:30
Sudharsanan11
4d629df299 fix(stock): point stock entry client calls at services module
The stock_entry_handler modules were moved to services; update the
retention, expired-batch and subcontract call paths in the client
so the whitelisted methods resolve again.
2026-07-09 18:52:31 +05:30
Sudharsanan11
5956d3e092 fix(stock): accept dict for doc arg in apply_price_list
The type-hint refactor rejected the doc dict sent by the form
controller; broaden the annotation to match ctx and fix the
cts= kwarg typo in transaction_base.
2026-07-09 18:48:01 +05:30
Nabin Hait
d387155e16 test: seed current-dated USD<->INR exchange rate in bootstrap
Tests that create USD documents dated today() (e.g. Sales Order in
test_advance_payment_ledger_entry, USD BOM in test_routing) rely on
get_exchange_rate() finding a USD->INR Currency Exchange record. The
only seeded records are dated 2016, so the lookup misses and falls back
to an external API that is blocked in CI, returning 0. That surfaces as
"Exchange Rate is mandatory" on Sales Order validation and a
ZeroDivisionError in BOM.get_routing (hour_rate / conversion_rate).

Whether it passes depends on which shard incidentally committed the
2016 records first, making it an order-dependent flake that unrelated
PRs trip by shifting test distribution.

Seed today()-dated USD<->INR rates once in BootStrapTestData so the
lookup resolves deterministically without the external API. Rates
mirror the latest Currency Exchange test_records to keep cost
calculations unchanged.
2026-07-09 18:26:14 +05:30
Mohd Haris
758a837de4 fix: update BOM operations when routing is changed
The routing field handler only fetched operations from the routing when
the operations table was empty. When a new BOM version is created (via
"New Version"), operations are copied from the source BOM, so selecting a
different routing left the old operations in place - both in the form and
after saving.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:01:17 +05:30
Nabin Hait
174027bd57 refactor(accounts): extract deferred-accounting and document-schedule out of AccountsController
Continues the AccountsController service decomposition (Phase 5).

- Add accounts/services/deferred_accounting.py with DeferredAccountingService
  owning the deferred revenue/expense validations (income/expense account
  defaulting and service start/end date checks).
- Move the document-schedule orchestration (validate_all_documents_schedule
  and the invoice/non-invoice variants) into PaymentScheduleService, where
  they already delegated, removing the controller-to-service round trip.
- Update the three validate() call sites; keep
  validate_auto_repeat_subscription_dates on the controller (still called by
  buying/selling controllers).

No behavior change. accounts_controller.py 1818 -> 1745 lines.
2026-07-09 17:55:19 +05:30
Pandiyan P
8b3caeb578 fix(stock): pick list serial batch posting date (#56957)
* fix(stock): fall back to current date/time for serial and batch bundle posting datetime

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

Fixes #56951

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

The whitelisted add_serial_batch_ledgers only converted child_row into an
attribute-accessible frappe._dict when it arrived as a JSON string, and doc's
type hint only allowed Document | str. Frappe's JSON API delivers both as
plain dicts (see frappe.app.make_form_dict, which parses the request body
with orjson and only wraps the top-level dict, not nested values), so every
real request was rejected before the handler body ever ran: first with a
FrappeTypeError on doc, and once that's fixed, with an AttributeError on
child_row.serial_and_batch_bundle. parse_json already wraps a plain dict in
frappe._dict (and leaves a real Document instance untouched), so routing
child_row through it unconditionally fixes both.
2026-07-09 17:28:03 +05:30
Pandiyan P
e2178a5f19 feat(manufacturing): create material request for raw materials from work order (#56961)
* feat(manufacturing): create material request for raw materials from work order

allow raising a material transfer request directly from a work order,
mirroring the existing job card flow, so stores can fulfil it into wip
before the actual stock entry happens

* test(manufacturing): cover work order material request flow

verify the material request created from a work order carries the
right bom/purpose onto the resulting stock entry, and that the work
order status still moves to in process on a partial material-request
transfer
2026-07-09 17:27:21 +05:30
Nabin Hait
798680d2d5 refactor(stock): de-conditionalize BaseStockGLComposer via subclass hooks
Remove per-doctype branching from the shared stock GL composer so each
voucher owns its own behavior:

- Move the Stock Reconciliation voucher-detail synthesis out of
  BaseStockGLComposer.get_voucher_details into a
  StockReconciliationGLComposer.get_voucher_details override.
- Replace the hardcoded doctype allow-list in check_expense_account with
  an overridable class attribute enforce_pl_expense_account (default
  True). Vouchers that post the difference to a balance-sheet account
  (Stock Entry, Stock Reconciliation, Delivery Note) set it False.
- Add DeliveryNoteGLComposer to own the P&L-exempt rule and wire
  DeliveryNote.get_gl_entries to it.

No change to GL output; behavior is relocated, not altered.
2026-07-09 17:25:32 +05:30
mergify[bot]
d418dd9e70 fix(patch): moved create_company_custom_fields from pre_model_sync to post_model_sync (backport #56962) (backport #56965) (#56970)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-07-09 10:36:50 +00:00
Diptanil Saha
1885f9fb35 Merge pull request #56674 from diptanilsaha/fix/frappe_crm_sync
fix(crm_settings): skip allowed users check when frappe crm is installed locally
2026-07-09 15:30:46 +05:30
MochaMind
49228b458d fix: sync translations from crowdin (#56943) 2026-07-09 15:30:26 +05:30
Khushi Rawat
e3fb1340e1 Merge pull request #56964 from khushi8112/fix/depr-schedule-precision-match
fix: match depreciation schedule rows at currency precision to avoid duplicate JEs
2026-07-09 14:43:13 +05:30
khushi8112
947ed5dfe1 fix: match depreciation schedule rows at currency precision to avoid duplicate JEs 2026-07-09 14:14:44 +05:30
diptanilsaha
0f987d7135 chore: patch to clear out allowed users on crm_settings if frappe crm is installed on the site 2026-07-09 12:37:47 +05:30
diptanilsaha
2de423e225 fix(frappe_crm_api): handle failure for after_app_install and after_app_uninstall 2026-07-09 12:37:47 +05:30
diptanilsaha
c86aa2d6fe feat(crm_settings): auto-update crm sync settings on frappe crm install and uninstall 2026-07-09 12:37:41 +05:30
diptanilsaha
41badb3d74 fix(crm_settings): skip allowed users check when frappe crm is installed locally 2026-07-09 12:01:49 +05:30
Abdeali Chharchhodawala
95e2ce6d85 feat: add grouping by dimension functionality in financial reports (#54650)
Co-authored-by: Copilot <copilot@github.com>
2026-07-08 19:04:09 +05:30
Raffael Meyer
c12e3fba5e feat(sla): filter service level agreement link by document type (#56954) 2026-07-08 13:47:57 +02:00
Smit Vora
e7f81de159 Merge pull request #54855 from Abdeali099/growth-view-with-cfs-54675
refactor(financial-report): fix row transformation for growth calculations
2026-07-08 16:13:44 +05:30
Abdeali Chharchhoda
aad287d09e fix: enhance growth view filtering by validating period keys 2026-07-08 16:03:28 +05:30
Abdeali Chharchhoda
4c7499600c fix: update formatting of growth view for FS report 2026-07-08 16:03:28 +05:30
Abdeali Chharchhoda
698876672d refactor: simple utility for growth value computation for custom FS report 2026-07-08 16:03:28 +05:30
Abdeali Chharchhoda
c179460c98 refactor(financial-report): fix row transformation for growth calculations 2026-07-08 16:03:28 +05:30
Diptanil Saha
95212e5738 fix(tnc): get_terms_and_conditions render_template with safe_exec (#56944) 2026-07-08 00:18:35 +00:00
Diptanil Saha
8a940af7e1 fix: added permission checks on various whitelisted functions (#56745)
* fix: added permission checks on various whitelisted functions

* fix: permission checks on `get_party_account` and using "select" `ptype`

* test(`BootStrapTestData`): assign `Accounts User` role to User `test@example.com`
2026-07-08 05:29:52 +05:30
Shllokkk
c94973a932 Merge pull request #56926 from Shllokkk/pricing-rule-template-variant-validation
fix: validate template and its variant in the same Pricing Rule
2026-07-07 19:59:10 +05:30
Dany Robert
be10c8ced9 fix: precision issue causing reconciliation error (#54043)
* fix: precision issue causing reconciliation error

* chore: code styling changes

* test: precision causing reconciliation failure

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

* fix(test): incorrect assertion on status

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
2026-07-07 19:44:35 +05:30
pandiyan
2ec780cb35 fix: validate planned end date is not before planned start date in work order 2026-07-07 16:34:58 +05:30
MochaMind
eb37d3b14b fix: sync translations from crowdin (#56919) 2026-07-07 12:59:22 +02:00
Shllokkk
a88048b378 fix: validate template and its variant in the same Pricing Rule 2026-07-07 13:55:22 +05:30
pandiyan
5da878d25f perf: batch bin lookups in delivery note stock update
update_current_stock() in delivery_note.py used to call
frappe.db.get_value("Bin", ...) separately for every row in items and
every row in packed_items - so a delivery note with 200 items and 200
packed items made 400 separate database calls on every save.

now it groups item codes by warehouse and fetches bin data with one
query per distinct warehouse, then assigns actual_qty/projected_qty to
each row from that result - same values as before, far fewer database
calls, and no cross-product over-fetch across warehouses.
2026-07-07 13:00:08 +05:30
pandiyan
6beb3d2509 perf: avoid per-row Warehouse doc fetches in auto reorder job
get_item_warehouse_projected_qty ran an uncached frappe.get_doc per Bin
row to walk the warehouse parent chain, re-fetching the same ancestors
for every item sharing a warehouse. Preload the warehouse parent map
once and walk it in memory instead.
2026-07-07 11:43:52 +05:30
pandiyan
015fa68fc0 fix: make trend report based-on and group-by column labels translatable
based_wise_columns_query() and group_wise_column() in trends.py built
column labels as raw strings, so "Item", "Item Name", "Customer",
"Supplier", "Territory", "Currency", etc. never went through _() and
stayed in English regardless of the user's language, unlike the
period and total columns right next to them which were already
wrapped correctly.
2026-07-06 18:41:06 +05:30
S Sakthivel Murugan
b72ecdda0d test: add regression test for trends chart total row 2026-07-06 17:54:25 +05:30
S Sakthivel Murugan
e6f9149ad7 fix: use company currency instead of global default in report 2026-07-06 17:54:25 +05:30
ljain112
0691c7c7bc refactor: move functionality in postprocess for mapped doc 2026-07-06 17:41:56 +05:30
rohitwaghchaure
607f0e943f fix: workspace for stock and manufacturing (#56906) 2026-07-06 11:48:15 +00:00
MochaMind
be93530b5f fix: sync translations from crowdin (#56866) 2026-07-06 10:49:55 +02:00
pandiyan
e718a70b26 test: cover variant item_code/item_name rename on abbreviation change
Add regression coverage for the new abbreviation-rename propagation:
a simple item_code rename, item_name derived from a template whose
item_name differs from its item_code, and a manually customized
item_name getting rebuilt rather than left stale.
2026-07-06 14:15:40 +05:30
pandiyan
c0cfe5f363 fix: rename variant item_code/item_name when attribute abbreviation changes
Item Attribute abbreviations only got baked into a variant's item_code
and item_name at creation time (make_variant_item_code returns early
once item_code is set). Renaming an abbreviation afterwards left every
existing variant stuck with the stale code, silently out of sync with
its own attribute.

Detect abbreviation renames on Item Attribute save, find every variant
using the affected value, and rebuild+rename its item_code via
frappe.rename_doc so linked records follow along. item_name is rebuilt
in lockstep from the template's item_name, even if it had since been
customized, since both fields are meant to be derived from the same
abbreviation.
2026-07-06 14:15:29 +05:30
S Sakthivel Murugan
c7774a95e5 fix(asset): allow asset repair creation for fully depreciated assets 2026-07-06 14:13:06 +05:30
MochaMind
0688cedba2 chore: update POT file (#56899) 2026-07-05 20:29:59 +02:00
Mohsin Akhtar
41c00de4d3 Merge branch 'develop' into fix-variant-of-filter-item-list 2026-07-05 23:27:46 +05:30
Mohsin Akhtar
54da9fc27a fix: update modified timestamp in item.json 2026-07-05 23:26:06 +05:30
Mihir Kandoi
8aeca5922c Merge pull request #56905 from mihir-kandoi/pg-lock-races-and-advisory-valuation
fix(stock): close postgres locking races; gate batch valuation with a txn advisory lock
2026-07-05 22:42:18 +05:30
Mihir Kandoi
2ec469257e perf(stock): gate batch valuation with a txn advisory lock on postgres
On postgres, outward batch valuation row-locked the item's ENTIRE SLE / Serial and Batch Entry history (a separate plain SELECT ... FOR UPDATE per site, since FOR UPDATE is invalid with GROUP BY there). That writes a lock marker (xmax + WAL) on every historical tuple per outward movement - write amplification that grows with history forever - and locks nothing at all when the history is empty (negative-stock edge: two concurrent outwards don't serialize).

Replace all four history-wide postgres lock statements with one frappe.db.transaction_advisory_lock(("batch-valuation", item_code, warehouse)) at the top of BatchNoValuation.calculate_avg_rate's outward branch - every valuation read (bundle + the three deprecated paths) is downstream of it. Released at commit/rollback, participates in deadlock detection, and serializes regardless of history size, so the empty-history edge is closed by construction. Batch qty updates iterate in sorted order so concurrent vouchers lock Batch rows in the same sequence.

MariaDB is unchanged: it keeps the original grouped FOR UPDATE row locks (and its gap locks). Requires frappe#40621.

Test: outward delivery of a batched item must leave the xact advisory lock visible in pg_locks for the submitting transaction.
2026-07-05 22:31:45 +05:30
Mihir Kandoi
db58858c68 fix(stock): close postgres lock-then-read races in pick list and stock reservation
Postgres has no gap locks, so the lock-then-read pattern (plain SELECT ... FOR UPDATE before a grouped read) only serializes on rows that already exist. Two sites had reachable races where the lock set is empty or disjoint:

- Pick list: two pick lists against the same SO item submitted concurrently lock only docstatus=1 rows, so with no previously-submitted picks their lock sets are disjoint and both pass validate_picked_qty (over-pick; picked_qty last-writer-wins). Gate on the referenced Sales Order Item / Packed Item rows, which always exist.
- Stock reservation: the first concurrent reservations for an (item, warehouse) find no SRE rows to lock, so both pass and reserved qty can exceed actual. Gate on the Bin row, which exists once there is stock.

MariaDB is unchanged (its gap locks already serialize both; the gates are postgres-only). Also: ORDER BY on the small-set postgres lock selects for deterministic lock order, and the repost pre-lock in get_future_stock_vouchers selects a constant instead of shipping every matching SLE name to the client.
2026-07-05 22:31:29 +05:30
Nabin Hait
8abcb7decc Merge pull request #56301 from frappe/chore/payment-entry-purchase-coverage
test: purchase-side Payment Entry allocation coverage
2026-07-05 19:39:38 +05:30
Mihir Kandoi
c68bf89924 Merge pull request #56903 from mihir-kandoi/pg-second-order-groupby-docs
docs: catalog second-order GROUP BY traps (wrap-is-the-bug classes)
2026-07-05 19:11:14 +05:30
Mihir Kandoi
0922d85bf0 docs: catalog second-order GROUP BY traps (wrap-is-the-bug classes)
A full audit of the loose-GROUP-BY fixes found four recurring mistakes in the fixes themselves: incoherent Max/Min pairs over coupled columns, NULL-skipping Max on discriminators, Sum(x)*Max(y) fabricated arithmetic, and wrong-bound picks. Add them to the compatibility catalog (new 3.1) and to the Greptile review instructions so future PRs get flagged.
2026-07-05 19:00:47 +05:30
Mohd Haris
48418eadb0 fix(budget-variance): correct month shift in comparison chart
The Budget Variance Report chart plotted the actual expense one month
earlier than the table (e.g. July actual shown under June).

build_comparison_chart_data() collected budget columns using
fieldname.startswith("budget_"). The dimension column "budget_against"
also matches that prefix, so it was added as an extra leading entry to
budget_fields and labels, while actual_fields had no such leading entry.
This shifted every actual value one position ahead of its label.

Skip the "budget_against" dimension column so budget/actual values and
labels stay aligned per month.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:06:43 +05:30
rohitwaghchaure
7844905e21 fix: dark theme shop floor (#56887) 2026-07-05 16:33:08 +05:30
Mihir Kandoi
00e6b44701 Merge pull request #56897 from mihir-kandoi/pg-requested-items-required-date
fix(buying): show earliest schedule date as required date
2026-07-05 15:45:52 +05:30
Mihir Kandoi
e3a38e3733 Merge pull request #56896 from mihir-kandoi/pg-production-planning-arrival-qty
fix(manufacturing): scope Production Planning arrival qty to open POs
2026-07-05 15:43:04 +05:30
Mihir Kandoi
1d7bb19620 Merge pull request #56898 from mihir-kandoi/pg-trends-empty-row-guard
fix(controllers): guard empty group-by detail row in trends
2026-07-05 15:39:53 +05:30
Mihir Kandoi
8486578c05 Merge pull request #56894 from mihir-kandoi/pg-procurement-tracker-coherent-row
fix(buying): make Procurement Tracker rows coherent PO lines
2026-07-05 15:39:33 +05:30
Mihir Kandoi
df82c7ac1c Merge pull request #56893 from mihir-kandoi/pg-bom-groupby-phantom-pair
fix(manufacturing): keep bom_no/is_phantom_item pair coherent in get_bom_items_as_dict
2026-07-05 15:38:29 +05:30
Mihir Kandoi
4cb622b8e3 Merge pull request #56895 from mihir-kandoi/pg-budget-requested-amount-per-row
fix(controllers): compute budget requested amount per row
2026-07-05 15:37:56 +05:30
Mihir Kandoi
a5d0b25ac4 test: assert earliest schedule date wins for duplicate MR item rows 2026-07-05 15:32:02 +05:30
Mihir Kandoi
2a6bc517bc fix: exclude fully received PO lines from arrival qty and date 2026-07-05 15:29:18 +05:30
Mihir Kandoi
5d56a04a84 fix(controllers): guard empty group-by detail row in trends
With an explicit GROUP BY, a zero-match detail query returns no rows (the old bare aggregate returned one all-NULL row), so row1[0][0] would raise IndexError. Skip the entry instead.
2026-07-05 15:10:32 +05:30
Mihir Kandoi
b5aee6a9cd fix(buying): show earliest schedule date as required date
When a Material Request lists the same item on multiple rows, the consolidated row showed Max(schedule_date), understating urgency. Use Min - the earliest date the item is needed.
2026-07-05 15:10:17 +05:30
Mihir Kandoi
efd777a1a8 fix(manufacturing): scope Production Planning arrival qty to open POs
arrival_qty summed the all-time ordered qty of every submitted PO line for the item+warehouse, so it grew monotonically with purchase history. Sum the pending qty (qty - received_qty) on open POs instead, and take the earliest schedule date from the same scope.
2026-07-05 15:10:03 +05:30
Mihir Kandoi
e52b1d6cff fix(controllers): compute budget requested amount per row
get_requested_amount multiplied the pooled pending qty of all matching Material Request items by a single Max(rate), fabricating the total whenever rates differ and biasing it upward - making false Budget Exceeded stops more likely. Sum (stock_qty - ordered_qty) * rate per row instead, matching get_ordered_amount.
2026-07-05 15:09:49 +05:30
Mihir Kandoi
39d2a62692 fix(buying): make Procurement Tracker rows coherent PO lines
get_po_entries aggregated every non-key column with Max() over (PO, material_request_item), which could stitch values from different PO lines into a row that never existed (one line's item_code with another's qty and amount). Select one representative line per group instead: a subquery picks Min(child.name) per group under the same filters and the outer query reads all columns bare from that line. Row count is unchanged.
2026-07-05 15:09:21 +05:30
Mihir Kandoi
db5ab9fb35 fix(manufacturing): keep bom_no/is_phantom_item pair coherent in get_bom_items_as_dict
When a BOM lists the same item twice (one line phantom via sub-BOM P, one non-phantom via sub-BOM N), grouping by item_code and aggregating bom_no and is_phantom_item with independent Max() could pair one line's phantom flag with the other line's bom_no, exploding the wrong sub-BOM or silently dropping a direct requirement. Group by the pair instead so each line keeps a coherent (bom_no, is_phantom_item); the consumer accumulates duplicate keys and explodes phantom rows with their own qty. Same fix as 41da9eb7fc, which missed this single-level path.
2026-07-05 15:08:58 +05:30
Nabin Hait
17851ceaae Merge pull request #56877 from frappe/chore/test-appointment-booking-settings
test: add coverage for Appointment Booking Settings
2026-07-05 13:16:15 +05:30
Nabin Hait
d2f0ec883e Merge pull request #56880 from frappe/chore/test-email-campaign
test: add coverage for Email Campaign
2026-07-05 13:16:06 +05:30
Nabin Hait
86435bc961 Merge pull request #56891 from frappe/fix/company-coa-test-abbr-collision
test: fix flaky test_coa_based_on_country_template (abbreviation collision)
2026-07-05 12:57:43 +05:30
Nabin Hait
b418df595e Merge branch 'develop' into chore/test-appointment-booking-settings 2026-07-05 12:50:53 +05:30
Nabin Hait
70e1acdb6d Merge branch 'develop' into chore/test-email-campaign 2026-07-05 12:50:47 +05:30
Nabin Hait
2959bfcafa Merge pull request #56888 from frappe/chore/test-lower-deduction-certificate
test: add coverage for Lower Deduction Certificate
2026-07-05 12:50:05 +05:30
Nabin Hait
94afc2a05b Merge pull request #56889 from frappe/chore/test-italy-utils
test: add coverage for Italy e-invoice utility helpers
2026-07-05 12:49:54 +05:30
Nabin Hait
e82a9b9c3b Merge pull request #56890 from frappe/chore/test-import-supplier-invoice
test: add coverage for Import Supplier Invoice
2026-07-05 12:49:31 +05:30
Nabin Hait
df4fc6b186 test: use a unique company abbreviation to fix COA-template flake 2026-07-05 12:44:31 +05:30
Nabin Hait
e86f6fc89c Merge pull request #56849 from frappe/chore/test-stock-closing-entry
fix: Stock Closing Entry duplicate check misses contained date ranges
2026-07-05 12:18:35 +05:30
Nabin Hait
11023511ae Merge pull request #56856 from frappe/chore/test-job-card-dark-paths
test: cover Job Card quantity, docstatus and capacity logic
2026-07-05 12:18:23 +05:30
Nabin Hait
58ee02780f Merge pull request #56883 from nabinhait/sherlock/fix-dunning-outstanding-currency
fix: use transaction-currency outstanding on Dunning for foreign-currency invoices
2026-07-05 12:17:27 +05:30
Nabin Hait
8d289dfe52 Merge pull request #56881 from frappe/chore/test-campaign
fix: Campaign with a naming series breaks the UTM Campaign link
2026-07-05 12:16:50 +05:30
Nabin Hait
616ceb8126 test: add coverage for Import Supplier Invoice validation and country lookup 2026-07-05 12:16:09 +05:30
Nabin Hait
f2f1b2597d test: add coverage for Italy e-invoice utility helpers 2026-07-05 12:13:48 +05:30
Nabin Hait
c293cb8871 test: add coverage for Lower Deduction Certificate date validation 2026-07-05 12:11:34 +05:30
rohitwaghchaure
29be72fae4 fix: warning message for new item standard cost (#56885) 2026-07-04 23:29:32 +05:30
Nabin Hait
a9bb6b31df fix: use transaction-currency outstanding on Dunning for foreign-currency invoices
When a Sales Invoice is in a foreign currency (e.g. USD) but the receivable
account is in the company currency (e.g. INR), `outstanding_amount` on the
invoice is stored in the party account currency (INR). `postprocess_dunning`
was copying that value directly into the Dunning's Overdue Payment row, which
is expected to carry the transaction-currency (USD) amount.

The fix: when `party_account_currency != currency`, use
`payment_schedule[0].outstanding` (already maintained in transaction currency)
instead of `outstanding_amount`.

Closes #56006
2026-07-04 19:45:44 +05:30
Nabin Hait
2b1e922183 fix: don't hijack another Campaign's UTM mirror when display names collide 2026-07-04 19:42:13 +05:30
Nabin Hait
486a1c78b0 Merge pull request #56882 from frappe/fix/pos-invoice-reset-mop-attributeerror
fix: reset_mode_of_payments raises AttributeError on a POS Invoice
2026-07-04 19:37:40 +05:30
Nabin Hait
ca9dcbf2d7 fix: reuse the existing UTM Campaign mirror when campaign_name is edited 2026-07-04 18:45:58 +05:30
Nabin Hait
c969ee7bef test: use separate documents for the invalid and valid slot cases 2026-07-04 18:44:04 +05:30
Nabin Hait
a1f6ae56ff fix: removed unused import
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-07-04 18:39:52 +05:30
Nabin Hait
14c1b02025 Merge pull request #56879 from frappe/chore/test-contract-template
test: add coverage for Contract Template
2026-07-04 17:58:32 +05:30
Nabin Hait
3b335db64c Merge pull request #56878 from frappe/chore/test-crm-settings
test: add coverage for CRM Settings
2026-07-04 17:58:18 +05:30
Nabin Hait
99ed620dad fix: reset_mode_of_payments raises AttributeError on POS Invoice 2026-07-04 17:54:46 +05:30
Nabin Hait
e5b7c3f98c test: cover Job Card quantity, docstatus and capacity-overlap logic 2026-07-04 17:51:52 +05:30
Nabin Hait
10c6cda6db fix: detect contained/enclosing date ranges in Stock Closing Entry duplicate check 2026-07-04 17:47:22 +05:30
Nabin Hait
4b4afe12df Merge pull request #56832 from frappe/chore/test-repost-payment-ledger
test: add coverage for Repost Payment Ledger
2026-07-04 17:45:10 +05:30
Nabin Hait
9fdcfd5f58 fix: link UTM Campaign to the Campaign's document name, not campaign_name 2026-07-04 17:40:16 +05:30
Nabin Hait
5eaafd3025 test: add coverage for Campaign naming and UTM mirroring 2026-07-04 17:33:35 +05:30
Nabin Hait
79c359ef5d test: add coverage for Email Campaign date and recipient validation 2026-07-04 17:30:46 +05:30
Nabin Hait
34f3870f2a test: add coverage for Contract Template validation and rendering 2026-07-04 17:28:28 +05:30
Nabin Hait
7f903b63dd test: add coverage for CRM Settings sync and contact-us guards 2026-07-04 17:26:58 +05:30
Nabin Hait
1a2e3e03b3 test: add coverage for Appointment Booking Settings slot validation 2026-07-04 17:25:20 +05:30
Nabin Hait
4c26ec8cd9 test: make add_manually test distinguish manual mode from auto-loading 2026-07-04 16:48:52 +05:30
Nabin Hait
f68f53dec0 test: cover on-cutoff boundary in voucher loading 2026-07-04 16:48:52 +05:30
Nabin Hait
9901746e02 test: add coverage for Repost Payment Ledger 2026-07-04 16:48:52 +05:30
Nabin Hait
ae0cfbd3e1 test: assert the saved closing entry exists rather than a truthy name 2026-07-04 16:48:51 +05:30
Nabin Hait
e37ceb5f69 test: cover Stock Closing Entry duplicate date-range validation 2026-07-04 16:48:51 +05:30
Nabin Hait
807c52e32b Merge pull request #56855 from frappe/chore/test-stock-reservation-entry-dark-paths
test: cover Stock Reservation Entry validations and helper
2026-07-04 16:43:55 +05:30
Nabin Hait
38b91a2800 Merge pull request #56846 from frappe/chore/test-bisect-accounting-statements
test: add coverage for Bisect Accounting Statements
2026-07-04 16:43:28 +05:30
Nabin Hait
0f3b77a344 Merge pull request #56841 from frappe/chore/test-exchange-rate-revaluation
test: cover Exchange Rate Revaluation validation and gain/loss paths
2026-07-04 16:42:22 +05:30
ruthra kumar
66e70e312d Merge pull request #56852 from ruthra-kumar/race_condition_in_process_pcv
fix: race condition in process pcv
2026-07-04 16:22:16 +05:30
Mihir Kandoi
ac57e389a4 Merge pull request #56871 from mihir-kandoi/cast-error-postgres
fix: type cast error on postgres
2026-07-04 14:24:20 +05:30
Mihir Kandoi
093bbb07a7 fix: type cast error on postgres 2026-07-04 14:11:29 +05:30
Mihir Kandoi
57e7ceae24 Merge pull request #56859 from aerele/fix/item-form-permission-errors
Fix(stock): item form permission errors
2026-07-04 14:07:54 +05:30
rohitwaghchaure
8093e44746 feat: shop floor interface for operators (#55551)
* feat: shop floor interface for operators

* fix: documentation

* fix: UI/UX for shop floor

* fix: shop floor query and OEE edge cases from review

- Push the draft / To Manufacture condition into the Job Card query
  (or_filters) so a busy workstation's submitted history cannot fill
  the row limit and hide active drafts
- Clamp the OEE quality factor at zero when process loss exceeds
  completed qty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:31:37 +05:30
Nabin Hait
55c27d3dde Merge pull request #56853 from frappe/chore/test-payment-entry-dark-paths
test: cover untested Payment Entry field validations
2026-07-04 11:52:16 +05:30
Nabin Hait
b85776c00b Merge pull request #56843 from frappe/chore/test-share-transfer
test: cover Share Transfer consistency validations
2026-07-03 23:44:40 +05:30
Nabin Hait
0f7ee3a843 Merge pull request #56844 from frappe/chore/test-process-statement-of-accounts
test: cover Process Statement Of Accounts validation
2026-07-03 23:44:29 +05:30
Nabin Hait
e546132ac3 Merge pull request #56845 from frappe/chore/test-chart-of-accounts-importer
test: add coverage for Chart of Accounts Importer parsing
2026-07-03 23:44:20 +05:30
Nabin Hait
24a13d16bb Merge pull request #56847 from frappe/chore/test-asset-capitalization
test: cover Asset Capitalization row validations
2026-07-03 23:43:52 +05:30
Nabin Hait
17e7f91690 Merge pull request #56850 from frappe/chore/test-packing-slip
test: cover Packing Slip package-number and item validations
2026-07-03 23:43:43 +05:30
Nabin Hait
ef7fb1084d Merge pull request #56854 from frappe/chore/test-serial-batch-bundle-dark-paths
test: cover Serial and Batch Bundle helpers and validations
2026-07-03 23:43:04 +05:30
Nabin Hait
18d16fa5cf Merge pull request #56851 from frappe/chore/test-email-digest
test: cover Email Digest date-window calculations
2026-07-03 23:42:25 +05:30
Nikhil Kothari
dc09362454 fix: replace all old icons (#56864) 2026-07-03 23:03:28 +05:30
Nabin Hait
2000a9db36 Merge pull request #56842 from frappe/chore/test-bank-reconciliation-tool
test: cover Bank Reconciliation Tool date filter and message helper
2026-07-03 22:08:23 +05:30
Nabin Hait
dd35d977f7 Merge pull request #56829 from frappe/chore/test-process-subscription
test: add coverage for Process Subscription
2026-07-03 22:06:19 +05:30
Mihir Kandoi
4545dd939a Merge pull request #56837 from aerele/fix/manufacture-stock-entry-cost-center-default
fix: remove company default on cost center in stock entry detail
2026-07-03 21:11:52 +05:30
rohitwaghchaure
7b0c35caaf fix: auto fetch serial no from previous operation output (#56445)
* fix: auto fetch serial no from previous operation output

* fix: order by

* fix: warehouse for operations
2026-07-03 20:23:33 +05:30
rohitwaghchaure
341a07dffa fix: restrict state-changing whitelisted endpoints to POST (#56858)
Add methods=["POST"] to 50 whitelisted functions that create or modify
documents (get_doc followed by insert/save/submit), so they can no
longer be invoked via GET requests.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:47:13 +05:30
pandiyan
ef794f390c fix: skip item prices tab render for users without item price read access 2026-07-03 18:25:08 +05:30
pandiyan
8c7b2f4d3c fix: clear stray permission message when item dashboard has no warehouse access 2026-07-03 18:25:01 +05:30
rohitwaghchaure
9c911438f1 fix: do not rebook standard cost variance on non-update-stock purchase invoice (#56799) 2026-07-03 17:17:38 +05:30
ruthra kumar
a9ffdac806 chore: linter fix 2026-07-03 17:16:06 +05:30
ruthra kumar
dbc409736a refactor(test): row name based utility methods 2026-07-03 17:16:03 +05:30
Nabin Hait
008742bdbe test: exercise every mandatory field in the Stock Reservation Entry check 2026-07-03 17:08:27 +05:30
Nabin Hait
ed77392741 test: also accept a mid-range rounding loss allowance 2026-07-03 17:07:31 +05:30
Nabin Hait
3240411876 test: drop unused timedelta import 2026-07-03 17:05:53 +05:30
Nabin Hait
8f96e5f2aa test: replace lambda with nested def (ruff E731) 2026-07-03 17:03:51 +05:30
Nabin Hait
7b2f38cd6f test: cover Stock Reservation Entry validations and helper 2026-07-03 16:23:52 +05:30
Nabin Hait
8456e88d93 test: cover Serial and Batch Bundle helpers and in-memory validations 2026-07-03 16:20:41 +05:30
ruthra kumar
21f4603144 refactor: prevent whole table scan while scheduling next date
- helps in concurrency isolation
2026-07-03 16:19:35 +05:30
Nabin Hait
e0ea8eee1a test: cover untested Payment Entry field validations 2026-07-03 16:15:24 +05:30
Nabin Hait
c5ab9958ff test: cover Email Digest date-window calculations 2026-07-03 16:01:10 +05:30
Nabin Hait
113d914b9c test: cover Packing Slip package-number and item validations 2026-07-03 15:59:13 +05:30
Nabin Hait
ebd8547629 test: cover Asset Capitalization row validations 2026-07-03 15:54:17 +05:30
ruthra kumar
7e4045e828 fix: prevent repeatable read related concurrency errors
Process Period Closing Voucher and Process Period Closing Voucher
Details are trackers how the jobs are processed. Keep transactions on
them very short.
2026-07-03 15:28:28 +05:30
ruthra kumar
ff6881764b fix: race condition and repeatable read in process pcv
- Update using child table name to avoid scanning whole table, which
eventually leads to mariadb 1020 (REPEATABLE READ).
 - Avoid race condition in final summarization
2026-07-03 15:28:26 +05:30
Nabin Hait
28367f75e9 test: add coverage for Bisect Accounting Statements bisection 2026-07-03 14:57:47 +05:30
Nabin Hait
740c5a07ff test: add coverage for Chart of Accounts Importer parsing 2026-07-03 14:55:31 +05:30
Nabin Hait
3dfb3f385b test: cover Process Statement Of Accounts validation defaults 2026-07-03 14:53:22 +05:30
Nabin Hait
dae90e90df test: cover Share Transfer consistency validations 2026-07-03 14:50:52 +05:30
Nabin Hait
d1d592cf0c test: cover bank reconciliation date filter and auto-reconcile message 2026-07-03 14:48:21 +05:30
Nabin Hait
65500d5102 test: cover Exchange Rate Revaluation validation and gain/loss paths 2026-07-03 14:45:06 +05:30
Nabin Hait
23e3dd94c0 Merge pull request #56831 from frappe/chore/test-process-payment-reconciliation
fix: Process Payment Reconciliation drops bank/cash and cost center filters
2026-07-03 14:31:17 +05:30
Nabin Hait
6255d99fda Merge pull request #56827 from frappe/chore/test-subscription-plan
fix: Subscription Plan Monthly Rate under-bills across a year boundary
2026-07-03 14:30:25 +05:30
Nabin Hait
81d5eac0ea Merge pull request #56824 from frappe/chore/test-journal-entry-template
fix: Journal Entry Template rows must belong to its company
2026-07-03 14:29:28 +05:30
Nabin Hait
0b20438da9 test: mirror subscription test setup for known settings 2026-07-03 14:24:58 +05:30
Nabin Hait
8632019d2a Merge pull request #56830 from frappe/chore/test-account-closing-balance
test: add coverage for Account Closing Balance
2026-07-03 14:22:40 +05:30
Nabin Hait
c9960b4d51 fix: carry bank/cash account and cost center into Payment Reconciliation 2026-07-03 14:16:46 +05:30
Nabin Hait
2cc02e61d9 fix: validate Journal Entry Template rows belong to its company 2026-07-03 14:15:19 +05:30
Nabin Hait
6fc28edde9 Merge pull request #56828 from frappe/chore/test-cashier-closing
test: add coverage for Cashier Closing
2026-07-03 14:08:55 +05:30
Nabin Hait
196730c535 fix: bill all months across a year boundary in Monthly Rate plans 2026-07-03 14:08:20 +05:30
Nabin Hait
4d39f698bd Merge pull request #56823 from frappe/chore/test-party-link
test: add coverage for Party Link
2026-07-03 14:07:04 +05:30
Nabin Hait
0a7abe7144 Merge pull request #56822 from frappe/chore/test-mode-of-payment
test: add coverage for Mode of Payment
2026-07-03 14:06:50 +05:30
pandiyan
a168bb7ea4 test: cover cost center fallback to item group default in manufacture entry
the existing test_cost_center_for_manufacture only checks a raw material
row against an item-level override, which is set independently of the
":company" default guard and never exercised the bug.
2026-07-03 14:06:22 +05:30
pandiyan
edfa0a7a1d fix: remove company default on cost center in stock entry detail
the ":company" default pre-filled every row before set_default_cost_center()
ran, so its "if not row.cost_center" guard was always false and the
project/item group/brand priority chain in get_default_cost_center()
never ran.
2026-07-03 14:06:22 +05:30
Nabin Hait
5888cdf3a0 Merge pull request #56821 from frappe/chore/test-item-tax-template
test: add coverage for Item Tax Template
2026-07-03 14:04:03 +05:30
Nabin Hait
a1f413e8a8 Merge pull request #56820 from frappe/chore/test-monthly-distribution
test: add coverage for Monthly Distribution
2026-07-03 14:03:36 +05:30
Nabin Hait
cd167bdd40 Merge pull request #56819 from frappe/chore/test-bank-guarantee
test: add coverage for Bank Guarantee
2026-07-03 14:02:53 +05:30
Mihir Kandoi
5d48c44bbb Merge pull request #56826 from frappe/fix/stock-ledger-invariant-check-report
fix: FIFO queue checks and incorrect entries filter in stock ledger reports
2026-07-03 13:19:29 +05:30
rohitwaghchaure
ecc8ec672b fix: replay immutable SLE qty for serial/batch bundle valuation (#56814) 2026-07-03 12:15:07 +05:30
Mihir Kandoi
3b1e57966e test: drop redundant cleanup, db rolls back after each test 2026-07-03 12:12:46 +05:30
Nabin Hait
974571aba7 test: guard account lookups and cover dropped pr_instance filters 2026-07-03 12:08:11 +05:30
Nabin Hait
7d917e497a test: assert account-currency sums carry through the merge 2026-07-03 12:06:56 +05:30
Nabin Hait
e041e33860 test: reload invoice for outstanding and cover equal-time boundary 2026-07-03 12:06:20 +05:30
Nabin Hait
832b5a56bf test: lock current cross-year monthly-rate underbilling value 2026-07-03 12:05:31 +05:30
Nabin Hait
abded56174 test: guard account lookup and lock missing company-check behaviour 2026-07-03 12:04:42 +05:30
Nabin Hait
6f866545b9 test: complete supplier-primary assertions and lock uniqueness gap 2026-07-03 12:03:56 +05:30
Nabin Hait
147e1539dc test: guard account lookup and lock dead POS guard behaviour 2026-07-03 12:02:50 +05:30
Nabin Hait
f58ea8e17d test: guard account lookup and lock current tax-rate behaviour 2026-07-03 12:01:47 +05:30
Nabin Hait
9980d47524 test: lock current end-date behaviour and assert persisted state 2026-07-03 12:00:45 +05:30
Nabin Hait
97794b7ded test: add coverage for Process Payment Reconciliation 2026-07-03 11:41:21 +05:30
Mihir Kandoi
ef5f47fafd fix: address review comments
- restore mutated SLE after test via addCleanup
- explicit return False in has_difference
- comment the fifo_stock_diff guard for non-queue predecessors
2026-07-03 11:39:51 +05:30
Nabin Hait
c51edbd88e test: add coverage for Account Closing Balance 2026-07-03 11:39:50 +05:30
Nabin Hait
745f657a0e test: add coverage for Process Subscription 2026-07-03 11:37:02 +05:30
Nabin Hait
5c87e2e398 test: add coverage for Cashier Closing 2026-07-03 11:34:27 +05:30
Nabin Hait
3167e8ba77 test: add coverage for Subscription Plan 2026-07-03 11:31:30 +05:30
Mihir Kandoi
94ab09e4a3 fix: FIFO queue checks and incorrect entries filter in stock ledger reports
- 'Show Incorrect Entries' always returned an empty result (regression
  from #43619); now returns entries from one row before the first
  incorrect one
- FIFO queue columns were computed for serialized/batched SLEs that
  don't maintain a stock queue, showing false differences; left empty
  for such rows
- compare value/valuation differences at currency precision, qty at
  float precision
2026-07-03 11:29:44 +05:30
Nabin Hait
83d821d8c4 test: add coverage for Journal Entry Template 2026-07-03 11:28:56 +05:30
Nabin Hait
22dc51a57a test: add coverage for Party Link 2026-07-03 11:25:32 +05:30
Nabin Hait
df54382727 test: add coverage for Mode of Payment 2026-07-03 11:23:08 +05:30
Nabin Hait
3e9843059e test: add coverage for Item Tax Template 2026-07-03 11:19:56 +05:30
Nabin Hait
ccd2aae481 test: add coverage for Monthly Distribution 2026-07-03 11:18:11 +05:30
Nabin Hait
41000ea109 test: add coverage for Bank Guarantee 2026-07-03 11:14:54 +05:30
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
7d8d1eaec7 test: submit overpaid payment for GL coverage and sync received_amount 2026-07-02 14:15:38 +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
mergify[bot]
0d8c65a013 ci(mergify): upgrade configuration to current format 2026-07-01 20:50:03 +00:00
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
SowmyaArunachalam
07f641c48c fix(journal-entry): fetch outstanding on foreign currency 2026-06-29 21:48:38 +05:30
Raghav Ruia
69d5d2bbc1 refactor: extract negative stock confirmation into shared util
Deduplicate the identical confirmation dialog used by Item and Stock
Settings into erpnext.utils.confirm_negative_stock, and collapse the
message into a single translatable string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:38:23 +05:30
Shllokkk
ecb6d48ec0 fix: restrict jinja globals in process statement of accounts templates 2026-06-25 14:48:02 +05:30
Raghav Ruia
2bf9fcb817 feat: confirmation dialog when enabling negative stock on Item
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:06:30 +05:30
Nabin Hait
ac8b3f18c7 test: add purchase-side Payment Entry allocation coverage
- pay multiple purchase invoices with a single Payment Entry
- unallocated (advance) amount when a supplier payment is overpaid
- allocating more than a purchase invoice's outstanding amount is rejected
2026-06-22 14:13:18 +05:30
Mohsin Akhtar
2433129850 fix: show only template items in Variant Of filter 2026-06-21 16:10:43 +05:30
537 changed files with 125080 additions and 82370 deletions

View File

@@ -150,6 +150,34 @@ Add it only when it is functionally dependent on the existing select columns; ot
SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is
unchanged.
### 3.1 Second-order traps — when the `Max()`/`Min()` wrap itself is the bug
The wrap is only a no-op when the column is provably single-valued per group (**"`Max()` means
provably constant"**). When the column can genuinely vary, the wrap is a decision, and a full
audit of these fixes found four recurring mistakes:
- **Incoherent pair** — two semantically-coupled columns (a flag + a link:
`is_phantom_item` + `bom_no`; a discriminator + its value) aggregated with *independent*
`Max()`/`Min()` can pair values from **different rows** — a chimera row that never existed.
MariaDB's loose pick was at least row-coherent. Fix: group by the pair (when consumers
tolerate the extra rows), or select one **representative row** (`Min(child.name)` subquery +
join-back) so every column comes from the same line.
- **NULL-skipping** — `MAX`/`MIN` ignore NULLs, so `Max()` over a mostly-NULL discriminator
(an `original_item`-style column) *deterministically* returns the non-NULL value where
MariaDB could return NULL — deterministically wrong where the old behavior was only
intermittently wrong. Flag it wherever "no value" is a meaningful state (fallback gates,
dict keys).
- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a
number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation,
budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`.
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
average for a rate. A blind `Max` can understate urgency or overstate a figure.
Review heuristic: **if choosing between `Max` and `Min` would change the answer, the column is
not functionally dependent** — wrapping either is the wrong fix. Group by it, restructure, or
pick a bound for a stated reason, and cover the varying-group case with a test.
---
## 4. False positives — do NOT flag these

File diff suppressed because one or more lines are too long

View File

@@ -88,7 +88,6 @@ pull_request_rules:
actions:
merge:
method: squash
commit_message_template: |
{{ title }} (#{{ number }})
{{ body }}
commit_message_format:
title: pr-title
body: pr-body

View File

@@ -659,8 +659,15 @@ def _ensure_idle_system():
last_gl_update = None
try:
# We also lock inserts to GL entry table with for_update here.
last_gl_update = frappe.db.get_value("GL Entry", {}, "modified", for_update=True, wait=False)
if frappe.db.db_type == "postgres":
# The MariaDB branch blocks new GL inserts via the gap lock its for_update read takes;
# a postgres row lock never blocks inserts, so take an EXCLUSIVE table lock instead --
# writers block until the rename commits, readers don't. NOWAIT mirrors wait=False.
frappe.db.sql("LOCK TABLE `tabGL Entry` IN EXCLUSIVE MODE NOWAIT")
last_gl_update = frappe.db.get_value("GL Entry", {}, "modified")
else:
# We also lock inserts to GL entry table with for_update here.
last_gl_update = frappe.db.get_value("GL Entry", {}, "modified", for_update=True, wait=False)
except frappe.QueryTimeoutError:
# wait=False fails immediately if there's an active transaction.
last_gl_update = add_to_date(None, seconds=-1)

View File

@@ -24,7 +24,8 @@
"account_number": "11530"
},
"account_number": "115",
"is_group": 1
"is_group": 1,
"account_type": "Bank"
},
"Trade Receivables": {
"Trade Debtors": {
@@ -529,6 +530,13 @@
"account_number": "630",
"is_group": 1
},
"Accrued Manufacturing Expenses": {
"Accrued Expenses - Manufacturing": {
"account_number": "63510"
},
"account_number": "635",
"is_group": 1
},
"account_number": "63",
"is_group": 1
},
@@ -814,4 +822,4 @@
"root_type": "Expense"
}
}
}
}

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

@@ -22,12 +22,12 @@
"account_type": "Cash"
},
"Petty Cash Fund": {
"account_number": "1200",
"account_number": "1110",
"is_group": 1,
"root_type": "Asset",
"account_type": "Cash",
"Petty Cash Fund": {
"account_number": "1201",
"account_number": "1111",
"is_group": 0,
"root_type": "Asset",
"account_type": "Cash"
@@ -35,10 +35,16 @@
}
},
"Bank Accounts": {
"account_number": "1102",
"account_number": "1200",
"is_group": 1,
"root_type": "Asset",
"account_type": "Bank"
"account_type": "Bank",
"Cash in Bank - Checking Account": {
"account_number": "1201",
"is_group": 0,
"root_type": "Asset",
"account_type": "Bank"
}
},
"Advances to Officers & Employees": {
"account_number": "1290",
@@ -104,25 +110,20 @@
"account_number": "1511",
"is_group": 0,
"root_type": "Asset"
},
"Factory Overhead Variance": {
"account_number": "1512",
"is_group": 0,
"root_type": "Asset"
}
},
"Finished Goods": {
"account_number": "1520",
"account_number": "1540",
"is_group": 1,
"root_type": "Asset",
"Finished Goods Inventory": {
"account_number": "1531",
"account_number": "1541",
"is_group": 0,
"root_type": "Asset",
"account_type": "Stock"
},
"Inventory in Transit": {
"account_number": "1532",
"account_number": "1542",
"is_group": 0,
"root_type": "Asset",
"account_type": "Stock Adjustment"
@@ -268,7 +269,7 @@
"root_type": "Asset"
}
},
"System Development": {
"Intangible Assets": {
"account_number": "1940",
"is_group": 1,
"root_type": "Asset",
@@ -277,6 +278,17 @@
"is_group": 0,
"root_type": "Asset"
}
},
"Accumulated Amortization - Intangible Assets": {
"account_number": "1950",
"is_group": 1,
"root_type": "Asset",
"Accum Amortization - System Development": {
"account_number": "1951",
"is_group": 0,
"root_type": "Asset",
"account_type": "Accumulated Depreciation"
}
}
}
},
@@ -406,8 +418,7 @@
"Customer Deposits": {
"account_number": "2500",
"is_group": 0,
"root_type": "Liability",
"account_type": "Payable"
"root_type": "Liability"
}
},
"Non Current Liabilities": {
@@ -563,6 +574,28 @@
"is_group": 0,
"root_type": "Income"
}
},
"Exchange Gain": {
"account_number": "6030",
"is_group": 1,
"root_type": "Income",
"Exchange Gain - Detail": {
"account_number": "6031",
"is_group": 0,
"root_type": "Income",
"account_type": "Indirect Income"
}
},
"Gain on Asset Disposal": {
"account_number": "6040",
"is_group": 1,
"root_type": "Income",
"Gain on Asset Disposal - Detail": {
"account_number": "6041",
"is_group": 0,
"root_type": "Income",
"account_type": "Indirect Income"
}
}
}
},
@@ -575,7 +608,7 @@
"is_group": 1,
"root_type": "Expense",
"Cost of Goods Sold": {
"account_number": "5010",
"account_number": "5002",
"is_group": 0,
"root_type": "Expense",
"account_type": "Cost of Goods Sold"
@@ -828,20 +861,61 @@
"root_type": "Expense"
}
},
"Stock Adjustment": {
"Other Expenses": {
"account_number": "5200",
"is_group": 1,
"root_type": "Expense",
"Bank Charges": {
"account_number": "5201",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
},
"Interest Expenses Bank": {
"account_number": "5202",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
},
"Write Off": {
"account_number": "5203",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
},
"Exchange Loss": {
"account_number": "5204",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
},
"Loss on Asset Disposal": {
"account_number": "5205",
"is_group": 0,
"root_type": "Expense",
"account_type": "Indirect Expense"
}
},
"Provision For Income Tax": {
"account_number": "5300",
"is_group": 0,
"root_type": "Expense",
"account_type": "Tax"
},
"Stock Adjustment": {
"account_number": "5400",
"is_group": 0,
"root_type": "Expense",
"account_type": "Stock Adjustment"
},
"Round Off": {
"account_number": "5300",
"account_number": "5500",
"is_group": 0,
"root_type": "Expense",
"account_type": "Round Off"
},
"Expenses Included In Valuation": {
"account_number": "5400",
"account_number": "5600",
"is_group": 0,
"root_type": "Expense",
"account_type": "Expenses Included In Valuation"

View File

@@ -1,10 +1,59 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
aggregate_with_last_account_closing_balance,
generate_key,
)
from erpnext.tests.utils import ERPNextTestSuite
def entry(**overrides):
row = {"debit": 0, "credit": 0, "debit_in_account_currency": 0, "credit_in_account_currency": 0}
row.update(overrides)
return row
class TestAccountClosingBalance(ERPNextTestSuite):
pass
"""The closing-balance snapshot is built by merging this period's entries with the
previous period's. These lock the merge/key logic that drives that carry-forward."""
def test_matching_entries_are_summed(self):
# this is how a prior-period balance carries forward into the current one
merged = aggregate_with_last_account_closing_balance(
[
entry(account="Cash - _TC", debit=100, debit_in_account_currency=100),
entry(
account="Cash - _TC",
debit=50,
credit=20,
debit_in_account_currency=50,
credit_in_account_currency=20,
),
],
[],
)
self.assertEqual(len(merged), 1)
row = next(iter(merged.values()))
self.assertEqual(row["debit"], 150)
self.assertEqual(row["credit"], 20)
# the account-currency columns are accumulated in the same pass
self.assertEqual(row["debit_in_account_currency"], 150)
self.assertEqual(row["credit_in_account_currency"], 20)
def test_entries_are_kept_separate_per_dimension(self):
merged = aggregate_with_last_account_closing_balance(
[
entry(account="Cash - _TC", cost_center="CC1", debit=100, debit_in_account_currency=100),
entry(account="Cash - _TC", cost_center="CC2", debit=40, debit_in_account_currency=40),
],
[],
)
self.assertEqual(len(merged), 2)
def test_period_closing_flag_is_part_of_the_key(self):
# a P&L reversal (flag 0) and a closing-account entry (flag 1) for the same
# account must not merge, so the flag has to distinguish their keys
key_reversal, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=0), [])
key_closing, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=1), [])
self.assertNotEqual(key_reversal, key_closing)

View File

@@ -359,3 +359,13 @@ def create_accounting_dimensions_for_doctype(doctype):
create_custom_field(doctype, df, ignore_validate=True)
frappe.clear_cache(doctype=doctype)
def get_dimension_fieldname(dim_doctype: str) -> str:
"""
Return the `GL Entry` fieldname for a given dimension.
"""
if dim_doctype in ("Cost Center", "Project"):
return frappe.scrub(dim_doctype)
return frappe.db.get_value("Accounting Dimension", {"document_type": dim_doctype}, "fieldname")

View File

@@ -6,7 +6,7 @@ frappe.ui.form.on("Accounting Dimension Filter", {
let help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<p>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
{{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}}
</p>
</td></tr>

View File

@@ -22,6 +22,8 @@
"allow_multi_currency_invoices_against_single_party_account",
"confirm_before_resetting_posting_date",
"preview_mode",
"stock_expense_section",
"book_stock_expense_gl_entries",
"analytics_section",
"enable_discounts_and_margin",
"enable_accounting_dimensions",
@@ -76,6 +78,8 @@
"over_billing_allowance",
"credit_controller",
"role_allowed_to_over_bill",
"enable_overdue_billing_threshold",
"role_allowed_to_bypass_overdue_billing",
"column_break_11",
"assets_tab",
"asset_settings_section",
@@ -272,6 +276,21 @@
"label": "Role Allowed to over bill ",
"options": "Role"
},
{
"default": "0",
"description": "Block submitting a new Sales Invoice when the customer's overdue amount exceeds the Overdue Billing Threshold set on the customer.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Enable Overdue Billing Threshold"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
"description": "Users with this role can still submit invoices for customers over their overdue billing threshold.",
"fieldname": "role_allowed_to_bypass_overdue_billing",
"fieldtype": "Link",
"label": "Role allowed to bypass overdue billing limit",
"options": "Role"
},
{
"fieldname": "period_closing_settings_section",
"fieldtype": "Section Break"
@@ -757,6 +776,18 @@
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
"fieldname": "column_break_mfor",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_expense_section",
"fieldtype": "Section Break",
"label": "Stock Expense Accounting"
},
{
"default": "0",
"description": "Books Purchase Expense and Expenses Added To Stock account pairs against stock value. On enabling this, the accounts become mandatory in Company or Item Defaults for Purchase Receipt, Purchase Invoice, Stock Entry, Stock Reconciliation and Landed Cost Voucher",
"fieldname": "book_stock_expense_gl_entries",
"fieldtype": "Check",
"label": "Book Stock Expense GL Entries"
}
],
"grid_page_length": 50,
@@ -765,7 +796,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-24 12:59:41.868865",
"modified": "2026-07-15 17:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -62,6 +62,7 @@ class AccountsSettings(Document):
book_asset_depreciation_entry_automatically: DF.Check
book_deferred_entries_based_on: DF.Literal["Days", "Months"]
book_deferred_entries_via_journal_entry: DF.Check
book_stock_expense_gl_entries: DF.Check
book_tax_discount_loss: DF.Check
calculate_depr_using_total_days: DF.Check
check_supplier_invoice_uniqueness: DF.Check
@@ -77,6 +78,7 @@ class AccountsSettings(Document):
enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check
enable_loyalty_point_program: DF.Check
enable_overdue_billing_threshold: DF.Check
enable_party_matching: DF.Check
enable_subscription: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
@@ -96,6 +98,7 @@ class AccountsSettings(Document):
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
repost_allowed_types: DF.Table[RepostAllowedTypes]
role_allowed_to_bypass_overdue_billing: DF.Link | None
role_allowed_to_over_bill: DF.Link | None
role_to_notify_on_depreciation_failure: DF.Link | None
role_to_override_stop_action: DF.Link | None
@@ -151,6 +154,10 @@ class AccountsSettings(Document):
toggle_subscription_sections(not self.enable_subscription)
clear_cache = True
if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold:
toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold)
clear_cache = True
if clear_cache:
frappe.clear_cache()
@@ -242,6 +249,10 @@ def toggle_subscription_sections(hide):
create_property_setter_for_hiding_field(doctype, "subscription_section", hide)
def toggle_overdue_billing_threshold_field(hide):
create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide)
def create_property_setter_for_hiding_field(doctype, field_name, hide):
make_property_setter(
doctype,

View File

@@ -107,7 +107,7 @@ def get_party_bank_account(party_type, party):
)
def get_default_company_bank_account(company, party_type, party):
def get_default_company_bank_account(company, party_type, party, ignore_permissions=True):
default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account")
if default_company_bank_account:
if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"):
@@ -118,6 +118,14 @@ def get_default_company_bank_account(company, party_type, party):
"Bank Account", {"company": company, "is_company_account": 1, "is_default": 1}
)
if not ignore_permissions:
default_company_bank_account = (
default_company_bank_account
if default_company_bank_account
and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select")
else None
)
return default_company_bank_account
@@ -188,7 +196,7 @@ def get_closing_balance_as_per_statement(bank_account: str, date: str):
return {"balance": 0, "date": None}
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def set_closing_balance_as_per_statement(bank_account: str, date: str | datetime.date, balance: float):
"""
Set the closing balance as per statement for a bank account and date

View File

@@ -1,8 +1,76 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.accounts.doctype.bank_guarantee.bank_guarantee import get_voucher_details
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
BANK = "_Test BG Bank"
class TestBankGuarantee(ERPNextTestSuite):
pass
"""Bank Guarantee records a guarantee issued/received against a customer or
supplier. validate() needs a party; on_submit() needs the bank details filled in."""
def setUp(self):
frappe.set_user("Administrator")
if not frappe.db.exists("Bank", BANK):
frappe.get_doc({"doctype": "Bank", "bank_name": BANK}).insert()
def make_bg(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Bank Guarantee")
doc.bg_type = args.bg_type or "Receiving"
doc.amount = args.amount if args.amount is not None else 1000
doc.start_date = args.start_date or "2026-06-01"
if args.end_date:
doc.end_date = args.end_date
doc.customer = args.get("customer", "_Test Customer")
doc.supplier = args.get("supplier")
# fields on_submit requires — present by default, cleared per-test to assert the guard
doc.bank_guarantee_number = args.get("bank_guarantee_number", "BG-001")
doc.name_of_beneficiary = args.get("name_of_beneficiary", "Test Beneficiary")
doc.bank = args.get("bank", BANK)
return doc
def test_validate_requires_customer_or_supplier(self):
doc = self.make_bg(customer=None)
self.assertRaises(frappe.ValidationError, doc.insert)
def test_submit_requires_guarantee_number(self):
doc = self.make_bg(bank_guarantee_number="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_submit_requires_beneficiary_name(self):
doc = self.make_bg(name_of_beneficiary="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_submit_requires_bank(self):
doc = self.make_bg(bank="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_valid_guarantee_submits(self):
doc = self.make_bg()
doc.insert()
doc.submit()
self.assertEqual(frappe.db.get_value("Bank Guarantee", doc.name, "docstatus"), 1)
def test_get_voucher_details_for_receiving(self):
so = make_sales_order()
details = get_voucher_details("Receiving", so.name)
self.assertEqual(details.customer, so.customer)
self.assertEqual(flt(details.grand_total), flt(so.grand_total))
def test_end_date_before_start_date_is_not_validated(self):
# SUSPECTED BUG: validate() never checks that end_date >= start_date, so a
# guarantee that expires before it starts saves cleanly. Locking the current
# (wrong) behaviour so a future fix that adds the check trips this test.
doc = self.make_bg(start_date="2026-06-30", end_date="2026-06-01")
doc.insert()
self.assertTrue(frappe.db.exists("Bank Guarantee", doc.name))

View File

@@ -116,7 +116,7 @@ def get_account_balance(bank_account: str, till_date: str | date, company: str):
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def update_bank_transaction(
bank_transaction_name: str, reference_number: str, party_type: str | None = None, party: str | None = None
):
@@ -146,7 +146,7 @@ def update_bank_transaction(
)[0]
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_journal_entry_bts(
bank_transaction_name: str,
reference_number: str | None = None,
@@ -305,7 +305,7 @@ def create_journal_entry_bts(
return reconcile_vouchers(bank_transaction_name, vouchers, is_new_voucher=True)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_payment_entry_bts(
bank_transaction_name: str,
reference_number: str | None = None,
@@ -500,7 +500,7 @@ def create_bulk_internal_transfer(bank_transaction_names: list[str | int], bank_
return output
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_internal_transfer(
bank_transaction_name: str | int,
posting_date: str | date,
@@ -1057,7 +1057,7 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
return alert_message, indicator
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
# updated clear date of all the vouchers based on the bank transaction
vouchers = frappe.parse_json(vouchers)

View File

@@ -8,6 +8,7 @@ from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
get_auto_reconcile_message,
get_bank_transactions,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
@@ -97,3 +98,40 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def make_bank_transaction(self, date, deposit=100):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": date,
"deposit": deposit,
"bank_account": self.bank_account,
"currency": "INR",
}
)
.save()
.submit()
)
def test_get_bank_transactions_excludes_dates_after_to_date(self):
self.make_bank_transaction(date=today())
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_auto_reconcile_message_for_no_matches(self):
message, indicator = get_auto_reconcile_message([], [])
self.assertEqual(indicator, "blue")
self.assertIn("No matches", message)
def test_auto_reconcile_message_counts_and_pluralizes(self):
# reconciled count is reported and the indicator turns green
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
self.assertEqual(indicator, "green")
self.assertIn("2 Transaction(s) Reconciled", message)
# partially-reconciled label is singular for one, plural for many
singular, _ = get_auto_reconcile_message(["p1"], [])
self.assertIn("1 Transaction Partially Reconciled", singular)
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
self.assertIn("2 Transactions Partially Reconciled", plural)

View File

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

View File

@@ -557,7 +557,7 @@ class BankStatementImportLog(Document):
docname=self.name,
)
if self.closing_balance and self.closing_balance > 0 and self.end_date:
if self.closing_balance is not None and self.end_date:
set_closing_balance_as_per_statement(
self.bank_account, frappe.utils.getdate(self.end_date), self.closing_balance
)

View File

@@ -397,7 +397,7 @@ def unreconcile_transaction(transaction_name: str | int):
frappe.get_doc(voucher["doctype"], voucher["name"]).cancel()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type: str, voucher_id: str | int):
"""
Removes a single payment entry from a bank transaction - for example only undoing one voucher instead of undoing the entire transaction

View File

@@ -34,7 +34,7 @@ def upload_bank_statement():
return {"columns": columns, "data": data}
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_bank_entries(columns: str, data: str | list, bank_account: str):
header_map = get_header_mapping(columns, bank_account)

View File

@@ -184,7 +184,7 @@ class BisectAccountingStatements(Document):
self.get_report_summary()
self.update_node()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def bisect_left(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
@@ -198,7 +198,7 @@ class BisectAccountingStatements(Document):
else:
frappe.msgprint(_("No more children on Left"))
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def bisect_right(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
@@ -212,7 +212,7 @@ class BisectAccountingStatements(Document):
else:
frappe.msgprint(_("No more children on Right"))
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def move_up(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)

View File

@@ -1,11 +1,47 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import datetime
import frappe
from frappe.utils import getdate
from erpnext.tests.utils import ERPNextTestSuite
class TestBisectAccountingStatements(ERPNextTestSuite):
pass
"""The tool bisects a date range into a tree of Bisect Nodes down to single days.
These cover the date validation and that the bisection cleanly partitions the range."""
def setUp(self):
frappe.set_user("Administrator")
frappe.db.delete("Bisect Nodes")
def _leaf_days(self):
leaves = frappe.get_all(
"Bisect Nodes",
filters={"left_child": ["is", "not set"]},
fields=["period_from_date", "period_to_date"],
)
# every leaf spans a single day
for leaf in leaves:
self.assertEqual(getdate(leaf.period_from_date), getdate(leaf.period_to_date))
return sorted(getdate(leaf.period_from_date) for leaf in leaves)
def test_validate_dates_rejects_reversed_range(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.from_date = "2026-01-08"
doc.to_date = "2026-01-01"
self.assertRaises(frappe.ValidationError, doc.validate)
def test_bfs_partitions_range_into_single_days(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.bfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
# the 8-day span Jan 1..Jan 8 becomes exactly 8 contiguous single-day leaves
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
def test_dfs_produces_the_same_partition_as_bfs(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.dfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])

View File

@@ -878,7 +878,7 @@ def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
return from_year.year_start_date, to_year.year_end_date
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def revise_budget(budget_name: str):
old_budget = frappe.get_doc("Budget", budget_name)

View File

@@ -1,8 +1,67 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# 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.tests.utils import ERPNextTestSuite
DATE = "2026-06-15"
class TestCashierClosing(ERPNextTestSuite):
pass
"""Cashier Closing reconciles a shift: it pulls outstanding invoices in a
date/time window and rolls payments, expense, custody and returns into net_amount."""
def setUp(self):
frappe.set_user("Administrator")
def make_invoice_in_window(self, rate=100):
si = create_sales_invoice(rate=rate, qty=1, posting_date=DATE, do_not_submit=True)
si.posting_time = "10:30:00"
si.submit()
si.reload() # read outstanding_amount as persisted after submit
return si
def make_closing(self, user="Administrator", payments=None, **args):
doc = frappe.new_doc("Cashier Closing")
doc.user = user
doc.date = args.get("date", DATE)
doc.from_time = args.get("from_time", "09:00:00")
doc.time = args.get("time", "18:00:00")
for amount in payments or []:
doc.append("payments", {"mode_of_payment": "Cash", "amount": amount})
doc.expense = args.get("expense", 0)
doc.custody = args.get("custody", 0)
doc.returns = args.get("returns", 0)
return doc
def test_from_time_must_be_before_to_time(self):
doc = self.make_closing(from_time="18:00:00", time="09:00:00")
self.assertRaises(frappe.ValidationError, doc.save)
def test_equal_from_and_to_time_is_rejected(self):
# validate_time uses >=, so a zero-length window is also blocked
doc = self.make_closing(from_time="09:00:00", time="09:00:00")
self.assertRaises(frappe.ValidationError, doc.save)
def test_net_amount_rolls_up_outstanding_and_adjustments(self):
si = self.make_invoice_in_window(rate=100)
doc = self.make_closing(payments=[500], expense=50, custody=30, returns=20)
doc.save()
# the in-window invoice is picked up as outstanding
self.assertEqual(doc.outstanding_amount, si.outstanding_amount)
# net = payments + outstanding + expense - custody + returns
self.assertEqual(doc.net_amount, 500 + si.outstanding_amount + 50 - 30 + 20)
def test_outstanding_is_scoped_to_the_invoice_owner(self):
# The invoice is created by Administrator; a closing for a different user does
# not see it. NOTE: get_outstanding keys on Sales Invoice.owner (the document
# creator) rather than an explicit cashier/POS-user field, which is fragile when
# invoices are created by a shared or system user.
self.make_invoice_in_window(rate=100)
doc = self.make_closing(user="Guest", payments=[500])
doc.save()
self.assertEqual(doc.outstanding_amount, 0)
self.assertEqual(doc.net_amount, 500)

View File

@@ -220,6 +220,7 @@ def build_forest(data):
for row in data:
account_name, parent_account, account_number, parent_account_number = row[0:4]
if account_number:
account_number = cstr(account_number).strip()
account_name = f"{account_number} - {account_name}"
if parent_account_number:
parent_account_number = cstr(parent_account_number).strip()

View File

@@ -1,8 +1,54 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer import (
build_forest,
validate_columns,
validate_missing_roots,
)
from erpnext.tests.utils import ERPNextTestSuite
# columns: account_name, parent_account, account_number, parent_account_number,
# is_group, account_type, root_type, account_currency
ROOT = ["Assets", "Assets", "", "", 1, "", "Asset", "INR"]
CHILD = ["Cash", "Assets", "", "", 0, "Cash", "Asset", "INR"]
class TestChartofAccountsImporter(ERPNextTestSuite):
pass
"""The importer parses an uploaded CoA into a nested tree and validates its
shape. These cover the parsing/validation helpers without a file upload."""
def test_validate_columns_rejects_blank_file(self):
self.assertRaises(frappe.ValidationError, validate_columns, [])
def test_validate_columns_requires_eight_columns(self):
self.assertRaises(frappe.ValidationError, validate_columns, [["a", "b", "c"]])
# the standard template width passes
validate_columns([ROOT])
def test_build_forest_nests_child_under_parent(self):
forest = build_forest([ROOT, CHILD])
self.assertIn("Assets", forest)
self.assertIn("Cash", forest["Assets"])
def test_build_forest_rejects_unknown_parent(self):
orphan = ["Cash", "Missing Parent", "", "", 0, "Cash", "Asset", "INR"]
self.assertRaises(frappe.ValidationError, build_forest, [orphan])
def test_build_forest_requires_account_name(self):
nameless = ["", "Assets", "", "", 0, "Cash", "Asset", "INR"]
self.assertRaises(frappe.ValidationError, build_forest, [ROOT, nameless])
def test_validate_missing_roots_requires_all_root_types(self):
present = ("Asset", "Liability", "Expense", "Income") # Equity missing
self.assertRaises(
frappe.ValidationError,
validate_missing_roots,
[{"root_type": rt} for rt in present],
)
# all five root types present -> no error
validate_missing_roots(
[{"root_type": rt} for rt in ("Asset", "Liability", "Expense", "Income", "Equity")]
)

View File

@@ -46,7 +46,7 @@ class ChequePrintTemplate(Document):
pass
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_or_update_cheque_print_format(template_name: str):
frappe.only_for("System Manager")

View File

@@ -169,23 +169,10 @@ frappe.ui.form.on("Dunning", {
},
get_dunning_letter_text: function (frm) {
if (frm.doc.dunning_type) {
frappe.call({
method: "erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text",
args: {
dunning_type: frm.doc.dunning_type,
language: frm.doc.language,
doc: frm.doc,
},
callback: function (r) {
if (r.message) {
frm.set_value("body_text", r.message.body_text);
frm.set_value("closing_text", r.message.closing_text);
frm.set_value("language", r.message.language);
} else {
frm.set_value("body_text", "");
frm.set_value("closing_text", "");
}
},
frm.call("get_dunning_letter_text").then((r) => {
if (!r.exc) {
frm.refresh_fields();
}
});
}
},

View File

@@ -163,6 +163,46 @@ class Dunning(AccountsController):
"Serial and Batch Bundle",
]
@frappe.whitelist()
def get_dunning_letter_text(self):
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
if not self.dunning_type:
return
filters = {"parent": self.dunning_type, "is_default_language": 1}
if self.language:
filters.pop("is_default_language")
filters["language"] = self.language
letter_text = frappe.db.get_value(DOCTYPE, filters, FIELDS, as_dict=True)
if not letter_text:
msg = (
_("Dunning Letter for Dunning Type {0} in language '{1}' not found.").format(
frappe.bold(self.dunning_type), frappe.bold(self.language)
)
if self.language
else _("Dunning Letter for Dunning Type {0} not found.").format(
frappe.bold(self.dunning_type)
)
)
frappe.msgprint(msg, alert=True, indicator="yellow")
self.body_text = (
frappe.render_template(letter_text.body_text, self.as_dict(), restrict_globals=True)
if letter_text
else None
)
self.closing_text = (
frappe.render_template(letter_text.closing_text, self.as_dict(), restrict_globals=True)
if letter_text
else None
)
self.language = letter_text.language if letter_text else self.language
def update_linked_dunnings(doc, previous_outstanding_amount):
if (
@@ -241,34 +281,3 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
& (overdue_payment.sales_invoice == sales_invoice)
)
).run(as_dict=True)
@frappe.whitelist()
def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict:
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
doc = frappe.parse_json(doc)
if not language:
language = doc.get("language")
letter_text = None
if language:
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1
)
if not letter_text:
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1
)
if not letter_text:
return {}
return {
"body_text": frappe.render_template(letter_text.body_text, doc),
"closing_text": frappe.render_template(letter_text.closing_text, doc),
"language": letter_text.language,
}

View File

@@ -12,6 +12,7 @@ from erpnext.accounts.doctype.sales_invoice.mapper import (
create_dunning as create_dunning_from_sales_invoice,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
create_sales_invoice,
create_sales_invoice_against_cost_center,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -152,6 +153,37 @@ class TestDunning(ERPNextTestSuite):
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
@ERPNextTestSuite.change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
def test_dunning_outstanding_uses_transaction_currency(self):
"""
Regression for #56006: dunning outstanding must be in the invoice transaction
currency, not in the party account currency.
A USD invoice posted against an INR receivable account stores
outstanding_amount in INR (party account currency). The overdue payment
row on the resulting Dunning must carry the USD amount, not the INR amount.
"""
si = create_sales_invoice(
posting_date=add_days(today(), -10),
currency="USD",
conversion_rate=50,
rate=100,
debit_to="Debtors - _TC",
)
# Sanity-check the invoice state before creating the dunning
self.assertEqual(si.currency, "USD")
self.assertEqual(si.outstanding_amount, 5000.0) # INR (party account currency)
self.assertEqual(si.payment_schedule[0].outstanding, 100.0) # USD (transaction currency)
dunning = create_dunning_from_sales_invoice(si.name)
self.assertEqual(len(dunning.overdue_payments), 1)
# Must reflect 100 USD, not 5000 INR mislabelled as USD
self.assertEqual(dunning.overdue_payments[0].outstanding, 100.0)
def test_dunning_not_affected_by_standalone_credit_note(self):
"""
Test that dunning is NOT resolved when a credit note has update_outstanding_for_self checked.

View File

@@ -3,7 +3,10 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import comma_and
from frappe.utils.jinja import validate_template
class DunningType(Document):
@@ -30,3 +33,134 @@ class DunningType(Document):
def autoname(self):
company_abbr = frappe.get_value("Company", self.company, "abbr")
self.name = f"{self.dunning_type} - {company_abbr}"
def validate(self):
self.validate_dunning_letter_text()
self.validate_income_account()
self.validate_cost_center()
self.set_default_dunning_type()
def validate_dunning_letter_text(self):
self.validate_languages()
self.validate_is_default_language()
self.validate_dunning_letter_text_templates()
def validate_income_account(self):
if not self.income_account:
return
account = frappe.get_cached_doc("Account", self.income_account)
msg = []
if account.company != self.company:
msg.append(
_(
"{0} doesn't belong to Company {1}. Please select an Income Account that belongs to Company {1}."
).format(frappe.bold(self.income_account), frappe.bold(self.company))
)
if account.disabled:
msg.append(
_("{0} is disabled. Please select a valid Income Account.").format(
frappe.bold(self.income_account)
)
)
if account.root_type != "Income":
msg.append(
_("{0} is not an Income Account. Please select a valid Income Account.").format(
frappe.bold(self.income_account)
)
)
if account.is_group:
msg.append(
_("{0} is a group account. Please select a non-group Income Account.").format(
frappe.bold(self.income_account)
)
)
if msg:
frappe.msgprint(
msg,
title=_("Income Account Validation Error"),
as_list=True,
raise_exception=frappe.ValidationError,
)
def validate_cost_center(self):
if not self.cost_center:
return
cost_center = frappe.get_cached_doc("Cost Center", self.cost_center)
msg = []
if cost_center.company != self.company:
msg.append(
_(
"{0} doesn't belong to Company {1}. Please select a Cost Center that belongs to Company {1}."
).format(frappe.bold(self.cost_center), frappe.bold(self.company))
)
if cost_center.disabled:
msg.append(
_("{0} is disabled. Please select an enabled Cost Center.").format(
frappe.bold(self.cost_center)
)
)
if cost_center.is_group:
msg.append(
_("{0} is a group Cost Center. Please select a non-group Cost Center.").format(
frappe.bold(self.cost_center)
)
)
if msg:
frappe.msgprint(
msg,
title=_("Cost Center Validation Error"),
as_list=True,
raise_exception=frappe.ValidationError,
)
def validate_languages(self):
languages = [d.language for d in self.dunning_letter_text]
if len(languages) == len(set(languages)):
return
frappe.throw(_("Duplicate languages found on Dunning Letter Text. Keep only one of them."))
def validate_is_default_language(self):
is_default_language_list = [
d.language for d in self.dunning_letter_text if d.is_default_language == 1
]
if len(is_default_language_list) <= 1:
return
frappe.throw(
_("{0} languages are marked as default languages. Please select only one of them.").format(
comma_and(is_default_language_list, add_quotes=True)
)
)
def validate_dunning_letter_text_templates(self):
for d in self.dunning_letter_text:
if d.body_text:
validate_template(d.body_text, restrict_globals=True)
if d.closing_text:
validate_template(d.closing_text, restrict_globals=True)
def set_default_dunning_type(self):
if self.is_default != 1:
return
frappe.db.set_value(
"Dunning Type",
{"company": self.company, "is_default": 1, "name": ["!=", self.name]},
"is_default",
0,
)

View File

@@ -1,9 +1,200 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.tests.utils import ERPNextTestSuite
def make_dunning_type(dunning_type, company="_Test Company", **kwargs):
doc = frappe.new_doc("Dunning Type")
doc.dunning_type = dunning_type
doc.company = company
doc.dunning_fee = kwargs.get("dunning_fee", 100)
doc.rate_of_interest = kwargs.get("rate_of_interest", 5)
doc.is_default = kwargs.get("is_default", 0)
if "income_account" in kwargs:
doc.income_account = kwargs["income_account"]
elif kwargs.get("income_account") is not False:
doc.income_account = "Sales - _TC" if company == "_Test Company" else "Sales - _TC1"
if "cost_center" in kwargs:
doc.cost_center = kwargs["cost_center"]
elif kwargs.get("cost_center") is not False:
doc.cost_center = "Main - _TC" if company == "_Test Company" else "Main - _TC1"
for row in kwargs.get("dunning_letter_text", [{"language": "en", "body_text": "Test body"}]):
doc.append("dunning_letter_text", row)
return doc
class TestDunningType(ERPNextTestSuite):
pass
def test_income_account_must_belong_to_company(self):
doc = make_dunning_type("_Test Dunning Wrong Company Account", income_account="Sales - _TC1")
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
def test_income_account_must_not_be_disabled(self):
disabled_account = frappe.get_doc(
{
"doctype": "Account",
"account_name": "_Test Disabled Income Account",
"parent_account": "Direct Income - _TC",
"company": "_Test Company",
"account_type": "Income Account",
"disabled": 1,
}
).insert()
doc = make_dunning_type("_Test Dunning Disabled Account", income_account=disabled_account.name)
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
def test_income_account_must_be_income_type(self):
doc = make_dunning_type("_Test Dunning Non Income Account", income_account="Debtors - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is not an Income Account", doc.insert)
def test_income_account_must_not_be_group(self):
doc = make_dunning_type("_Test Dunning Group Account", income_account="Income - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is a group account", doc.insert)
def test_income_account_is_optional(self):
doc = make_dunning_type("_Test Dunning No Income Account", income_account=False)
doc.insert()
self.assertFalse(doc.income_account)
def test_valid_income_account_passes(self):
doc = make_dunning_type("_Test Dunning Valid Income Account", income_account="Sales - _TC")
doc.insert()
self.assertEqual(doc.income_account, "Sales - _TC")
def test_cost_center_must_belong_to_company(self):
doc = make_dunning_type("_Test Dunning Wrong Company CC", cost_center="Main - _TC1")
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
def test_cost_center_must_not_be_disabled(self):
disabled_cc = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Disabled Cost Center",
"parent_cost_center": "_Test Company - _TC",
"company": "_Test Company",
"disabled": 1,
}
).insert()
doc = make_dunning_type("_Test Dunning Disabled CC", cost_center=disabled_cc.name)
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
def test_cost_center_must_not_be_group(self):
doc = make_dunning_type("_Test Dunning Group CC", cost_center="_Test Company - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is a group Cost Center", doc.insert)
def test_cost_center_is_optional(self):
doc = make_dunning_type("_Test Dunning No CC", cost_center=False)
doc.insert()
self.assertFalse(doc.cost_center)
def test_valid_cost_center_passes(self):
doc = make_dunning_type("_Test Dunning Valid CC", cost_center="Main - _TC")
doc.insert()
self.assertEqual(doc.cost_center, "Main - _TC")
def test_duplicate_languages_not_allowed(self):
doc = make_dunning_type(
"_Test Dunning Duplicate Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one"},
{"language": "en", "body_text": "Body two"},
],
)
self.assertRaisesRegex(frappe.ValidationError, "Duplicate languages found", doc.insert)
def test_unique_languages_allowed(self):
doc = make_dunning_type(
"_Test Dunning Unique Languages",
dunning_letter_text=[
{"language": "en", "body_text": "Body one"},
{"language": "de", "body_text": "Body two"},
],
)
doc.insert()
self.assertEqual(len(doc.dunning_letter_text), 2)
def test_only_one_default_language_allowed(self):
doc = make_dunning_type(
"_Test Dunning Multiple Default Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one", "is_default_language": 1},
{"language": "de", "body_text": "Body two", "is_default_language": 1},
],
)
self.assertRaisesRegex(
frappe.ValidationError, "languages are marked as default languages", doc.insert
)
def test_single_default_language_allowed(self):
doc = make_dunning_type(
"_Test Dunning Single Default Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one", "is_default_language": 1},
{"language": "de", "body_text": "Body two", "is_default_language": 0},
],
)
doc.insert()
self.assertEqual(doc.dunning_letter_text[0].is_default_language, 1)
def test_invalid_jinja_template_in_body_text_raises(self):
doc = make_dunning_type(
"_Test Dunning Invalid Body Template",
dunning_letter_text=[{"language": "en", "body_text": "{{ unclosed"}],
)
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
def test_invalid_jinja_template_in_closing_text_raises(self):
doc = make_dunning_type(
"_Test Dunning Invalid Closing Template",
dunning_letter_text=[
{"language": "en", "body_text": "Valid body", "closing_text": "{{ unclosed"}
],
)
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
def test_valid_jinja_template_passes(self):
doc = make_dunning_type(
"_Test Dunning Valid Template",
dunning_letter_text=[
{
"language": "en",
"body_text": "Outstanding amount is {{ outstanding_amount }}",
"closing_text": "Regards, {{ company }}",
}
],
)
doc.insert()
self.assertTrue(doc.name)
def test_set_default_dunning_type_unsets_previous_default(self):
first = make_dunning_type("_Test Dunning Default One", is_default=1)
first.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 1)
second = make_dunning_type("_Test Dunning Default Two", is_default=1)
second.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 0)
self.assertEqual(frappe.db.get_value("Dunning Type", second.name, "is_default"), 1)
def test_set_default_dunning_type_scoped_per_company(self):
company_1 = make_dunning_type("_Test Dunning Default Co1", is_default=1)
company_1.insert()
company_2 = make_dunning_type(
"_Test Dunning Default Co2",
company="_Test Company 1",
is_default=1,
)
company_2.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", company_1.name, "is_default"), 1)
self.assertEqual(frappe.db.get_value("Dunning Type", company_2.name, "is_default"), 1)

View File

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

View File

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

View File

@@ -132,7 +132,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
err = err.save().submit()
# Create JV for ERR
self.assertTrue(err.check_journal_entry_condition())
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
je = je.submit()
@@ -221,7 +222,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
err = err.save().submit()
# Create JV for ERR
self.assertTrue(err.check_journal_entry_condition())
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
je = je.submit()
@@ -298,3 +300,150 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_05_revaluation_journal_reversal(self):
"""
Test reversing of revaluation journals
"""
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.posting_date = today()
err.fetch_and_calculate_accounts_data()
self.assertEqual(len(err.accounts), 1)
err.save().submit()
gain_loss_account = err.get_for_unrealized_gain_loss_account()
usd_account = err.accounts[0].account
old_balance = err.accounts[0].balance_in_base_currency
new_balance = err.accounts[0].new_balance_in_base_currency
total_gain_loss = err.total_gain_loss
# Create JV for ERR
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
je = je.submit()
je.reload()
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
self.assertEqual(len(je.accounts), 3)
# A gain is credited to the gain/loss account, a loss is debited. The current
# exchange rate (from master data) may sit either side of the booked rate, so
# derive the column from the sign instead of assuming a gain.
gain_loss_debit = abs(total_gain_loss) if total_gain_loss < 0 else 0.0
gain_loss_credit = total_gain_loss if total_gain_loss > 0 else 0.0
expected = [
(usd_account, new_balance, 0.0, 100.0, 0.0),
(usd_account, 0.0, old_balance, 0.0, 100.0),
(gain_loss_account, gain_loss_debit, gain_loss_credit, gain_loss_debit, gain_loss_credit),
]
actual = []
for acc in je.accounts:
actual.append(
(
acc.account,
acc.debit,
acc.credit,
acc.debit_in_account_currency,
acc.credit_in_account_currency,
)
)
self.assertEqual(expected, actual)
# Assert reversals are not posted
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertFalse(ret.get("reversals_posted"))
err.make_reverse_journal()
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertTrue(ret.get("reversals_posted"))
reverse_jv = frappe.db.get_all(
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
)
self.assertIsNotNone(reverse_jv)
class TestExchangeRateRevaluationValidation(ERPNextTestSuite):
"""Validation and gain/loss calculation paths, exercised on the document directly
so they don't need the multi-currency GL setup the integration tests above build."""
def setUp(self):
frappe.set_user("Administrator")
self.company = "_Test Company"
def _revaluation_with_rows(self, rows, rounding_loss_allowance=0.05):
doc = frappe.new_doc("Exchange Rate Revaluation")
doc.company = self.company
doc.posting_date = today()
doc.rounding_loss_allowance = rounding_loss_allowance
for row in rows:
doc.append("accounts", row)
return doc
def test_rounding_loss_allowance_must_be_between_0_and_1(self):
for bad in (-0.1, 1, 1.5):
doc = self._revaluation_with_rows([], rounding_loss_allowance=bad)
self.assertRaises(frappe.ValidationError, doc.validate)
# values inside [0, 1) are accepted, at the lower bound and mid-range
for good in (0.0, 0.5):
self._revaluation_with_rows([], rounding_loss_allowance=good).validate()
def test_gain_loss_computed_and_split_by_zero_balance(self):
doc = self._revaluation_with_rows(
[
# open (unbooked) row: base balance moved 1000 -> 1100, a 100 gain
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
# already-settled (zero_balance) row carries a booked loss of 40
{"zero_balance": 1, "gain_loss": -40},
]
)
doc.validate()
# gain_loss is derived only for open rows; the zero-balance row keeps its value
self.assertEqual(doc.accounts[0].gain_loss, 100)
self.assertEqual(doc.gain_loss_unbooked, 100)
self.assertEqual(doc.gain_loss_booked, -40)
self.assertEqual(doc.total_gain_loss, 60)
def test_before_submit_drops_rows_without_gain_loss(self):
doc = self._revaluation_with_rows(
[
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500},
]
)
doc.validate() # second row nets to a 0 gain_loss
doc.remove_accounts_without_gain_loss()
self.assertEqual(len(doc.accounts), 1)
self.assertEqual(doc.accounts[0].gain_loss, 100)
def test_before_submit_requires_at_least_one_gain_loss_row(self):
doc = self._revaluation_with_rows(
[{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}]
)
doc.validate()
self.assertRaises(frappe.ValidationError, doc.remove_accounts_without_gain_loss)

View File

@@ -255,16 +255,27 @@ class FinancialReportEngine:
if filters.get("presentation_currency"):
frappe.msgprint(
title=_("Unsupported Feature"),
msg=_("Currency filters are currently unsupported in Custom Financial Report."),
indicator="orange",
title=_("Not Supported"),
msg=_("Currency filters are currently unsupported in Custom Financial Report"),
)
# Margin view is dependent on first row being an income account. Hence not supported.
# Way to implement this would be using calculated rows with formulas.
supported_views = ("Report", "Growth")
if (view := filters.get("selected_view")) and view not in supported_views:
frappe.msgprint(_("{0} view is currently unsupported in Custom Financial Report.").format(view))
frappe.msgprint(
indicator="orange",
title=_("Not Supported"),
msg=_("{0} view is currently unsupported in Custom Financial Report").format(view),
)
if filters.get("group_by_dimension"):
frappe.msgprint(
indicator="orange",
title=_("Not Supported"),
msg=_("Dimension-based grouping is currently unsupported in Custom Financial Report"),
)
def _initialize_context(self, filters: dict[str, Any]) -> ReportContext:
template_name = filters.get("report_template")
@@ -1860,28 +1871,51 @@ class GrowthViewTransformer:
self.formatted_rows = context.raw_data.get("formatted_data", [])
self.period_list = context.period_list
def transform(self) -> None:
def transform(self):
for row_data in self.formatted_rows:
if row_data.get("is_blank_line"):
continue
transformed_values = {}
for i in range(len(self.period_list)):
current_period = self.period_list[i]["key"]
if row_data.get("segment_values"):
self._transform_segmented_row(row_data)
else:
self._transform_single_row(row_data)
current_value = row_data[current_period]
previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0
def _compute_growth_values(self, source: dict) -> dict:
transformed = {}
if i == 0:
transformed_values[current_period] = current_value
else:
growth_percent = self._calculate_growth(previous_value, current_value)
transformed_values[current_period] = growth_percent
for i, period in enumerate(self.period_list):
current_period = period["key"]
current_value = source.get(current_period)
row_data.update(transformed_values)
if current_value in (None, ""):
continue
if i == 0:
transformed[current_period] = current_value
else:
previous_period = self.period_list[i - 1]["key"]
previous_value = source.get(previous_period) or 0
transformed[current_period] = self._calculate_growth(previous_value, current_value)
return transformed
def _transform_single_row(self, row_data: dict):
row_data.update(self._compute_growth_values(row_data))
def _transform_segmented_row(self, row_data: dict):
for seg_id, seg_data in row_data.get("segment_values", {}).items():
if seg_data.get("is_blank_line"):
continue
transformed = self._compute_growth_values(seg_data)
seg_data.update(transformed)
for period_key, value in transformed.items():
row_data[f"{seg_id}_{period_key}"] = value
def _calculate_growth(self, previous_value: float, current_value: float) -> float | None:
if current_value is None:
if current_value in (None, ""):
return None
if previous_value == 0 and current_value > 0:

View File

@@ -1,8 +1,62 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
TAX_ACCOUNT = "_Test Account VAT - _TC"
RECEIVABLE_ACCOUNT = "Debtors - _TC"
class TestItemTaxTemplate(ERPNextTestSuite):
pass
"""Item Tax Template validates its tax rows: each account must belong to the
company, be a tax-like account type, and appear only once."""
def setUp(self):
frappe.set_user("Administrator")
def make_template(self, rows, title="_Test ITT"):
doc = frappe.new_doc("Item Tax Template")
doc.title = f"{title} {frappe.generate_hash(length=6)}"
doc.company = COMPANY
for account, rate, not_applicable in rows:
doc.append(
"taxes",
{"tax_type": account, "tax_rate": rate, "not_applicable": not_applicable},
)
return doc
def test_valid_template_saves_and_is_named_with_abbr(self):
doc = self.make_template([(TAX_ACCOUNT, 9, 0)])
doc.insert()
self.assertTrue(doc.name.endswith(" - _TC"))
self.assertTrue(doc.name.startswith(doc.title))
def test_duplicate_tax_type_throws(self):
doc = self.make_template([(TAX_ACCOUNT, 9, 0), (TAX_ACCOUNT, 5, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_account_of_wrong_company_throws(self):
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
doc = self.make_template([(other_account, 9, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_disallowed_account_type_throws(self):
# a Receivable account is not Tax/Chargeable/Income/Expense
doc = self.make_template([(RECEIVABLE_ACCOUNT, 9, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_not_applicable_row_has_rate_zeroed(self):
doc = self.make_template([(TAX_ACCOUNT, 18, 1)])
doc.insert()
self.assertEqual(doc.taxes[0].tax_rate, 0)
def test_negative_tax_rate_is_accepted(self):
# SUSPECTED BUG: validate never bounds tax_rate, so a negative (or >100) rate
# saves silently. Locking the current (wrong) behaviour.
doc = self.make_template([(TAX_ACCOUNT, -5, 0)])
doc.insert()
self.assertEqual(doc.taxes[0].tax_rate, -5)

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

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

View File

@@ -94,11 +94,12 @@ class AssetService:
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
precision = je_row.precision("debit")
for d in depr_schedule or []:
if (
d.schedule_date == self.doc.posting_date
and not d.journal_entry
and d.depreciation_amount == flt(je_row.debit)
and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision)
):
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)

View File

@@ -45,6 +45,20 @@ class JournalEntryTemplate(Document):
def validate(self):
self.validate_party()
self.validate_account_company()
def validate_account_company(self):
"""Each row's account must belong to the template's company."""
for account in self.accounts:
if (
account.account
and frappe.get_cached_value("Account", account.account, "company") != self.company
):
frappe.throw(
_("Row {0}: Account {1} does not belong to company {2}").format(
account.idx, account.account, self.company
)
)
def validate_party(self):
"""

View File

@@ -1,9 +1,45 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestJournalEntryTemplate(ERPNextTestSuite):
pass
"""Journal Entry Template's only real rule is validate_party: party_type is
allowed only on Receivable/Payable accounts, and a party needs a party_type."""
def setUp(self):
frappe.set_user("Administrator")
def make_template(self, rows, company=COMPANY):
doc = frappe.new_doc("Journal Entry Template")
doc.template_title = f"_Test JET {frappe.generate_hash(length=6)}"
doc.company = company
doc.voucher_type = "Journal Entry"
doc.naming_series = frappe.get_meta("Journal Entry").get_field("naming_series").options.split("\n")[0]
for row in rows:
doc.append("accounts", row)
return doc
def test_party_type_only_on_receivable_or_payable_account(self):
# Cash is neither Receivable nor Payable, so a party_type here is invalid
doc = self.make_template([{"account": "Cash - _TC", "party_type": "Customer"}])
self.assertRaises(frappe.ValidationError, doc.validate)
def test_party_requires_party_type(self):
doc = self.make_template([{"account": "Debtors - _TC", "party": "_Test Customer"}])
self.assertRaises(frappe.ValidationError, doc.validate)
def test_account_from_other_company_is_rejected(self):
other_receivable = frappe.db.get_value(
"Account", {"company": "_Test Company 1", "account_type": "Receivable", "is_group": 0}, "name"
)
self.assertTrue(other_receivable, "need a receivable account in _Test Company 1")
doc = self.make_template(
[{"account": other_receivable, "party_type": "Customer", "party": "_Test Customer"}]
)
self.assertRaises(frappe.ValidationError, doc.insert)

View File

@@ -8,7 +8,7 @@ frappe.ui.form.on("Loyalty Program", {
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
${__("Notes")}
</h4>
<ul>

View File

@@ -5,9 +5,59 @@ import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestModeofPayment(ERPNextTestSuite):
pass
"""Mode of Payment validates its per-company default accounts (account company
must match the row, no company twice) and blocks disabling while a POS Profile
still references it."""
def setUp(self):
frappe.set_user("Administrator")
def make_mop(self, accounts=None, enabled=1):
doc = frappe.new_doc("Mode of Payment")
doc.mode_of_payment = f"_Test MoP {frappe.generate_hash(length=6)}"
doc.type = "General"
doc.enabled = enabled
for company, account in accounts or []:
doc.append("accounts", {"company": company, "default_account": account})
return doc
def test_valid_mode_of_payment_saves(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")])
doc.insert()
self.assertTrue(doc.name)
def test_account_of_wrong_company_throws(self):
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
doc = self.make_mop(accounts=[(COMPANY, other_account)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_repeating_company_throws(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC"), (COMPANY, "Debtors - _TC")])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_disabling_mode_referenced_by_pos_profile_is_not_blocked(self):
# SUSPECTED BUG: validate_pos_mode_of_payment queries "Sales Invoice Payment"
# rows with parenttype "POS Profile", but a POS Profile's payments are stored
# as "POS Payment Method" rows. The filter never matches, so the guard is dead
# and a mode still referenced by a POS Profile disables without complaint.
# Locking the current (wrong) behaviour so a fix to the guard trips this test.
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
make_pos_profile() # its payments row references the "Cash" mode of payment
cash = frappe.get_doc("Mode of Payment", "Cash")
cash.enabled = 0
cash.save()
self.assertEqual(frappe.db.get_value("Mode of Payment", "Cash", "enabled"), 0)
def test_disabling_unreferenced_mode_succeeds(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")], enabled=0)
doc.insert()
self.assertEqual(doc.enabled, 0)
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):

View File

@@ -1,8 +1,67 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import getdate
from erpnext.accounts.doctype.monthly_distribution.monthly_distribution import (
get_percentage,
get_periodwise_distribution_data,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestMonthlyDistribution(ERPNextTestSuite):
pass
"""Monthly Distribution spreads an amount across months. validate() enforces a
100% total; get_percentage() sums the months that fall inside a period window."""
def setUp(self):
frappe.set_user("Administrator")
def make_distribution(self, allocations):
doc = frappe.new_doc("Monthly Distribution")
doc.distribution_id = f"_Test MD {frappe.generate_hash(length=6)}"
for month, pct in allocations:
doc.append("percentages", {"month": month, "percentage_allocation": pct})
return doc
def test_get_months_populates_twelve_even_rows(self):
doc = frappe.new_doc("Monthly Distribution")
doc.distribution_id = "_Test MD Even"
doc.get_months()
self.assertEqual(len(doc.percentages), 12)
self.assertEqual(doc.percentages[0].month, "January")
self.assertEqual(doc.percentages[-1].month, "December")
self.assertEqual([d.idx for d in doc.percentages], list(range(1, 13)))
for d in doc.percentages:
self.assertAlmostEqual(d.percentage_allocation, 100.0 / 12, places=4)
# the auto-populated rows round to exactly 100 and pass validation
doc.validate()
def test_validate_rejects_total_other_than_100(self):
doc = self.make_distribution([("January", 50), ("February", 30)]) # sums to 80
self.assertRaises(frappe.ValidationError, doc.insert)
def test_get_percentage_sums_period_window(self):
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
doc.insert() # total is 100, so validate passes
# a quarter starting in January covers Jan+Feb+Mar
self.assertEqual(get_percentage(doc, getdate("2026-01-01"), 3), 100)
# a single month picks up only that month
self.assertEqual(get_percentage(doc, getdate("2026-02-01"), 1), 30)
# months with no row simply contribute 0 (there is no guard that all 12 exist)
self.assertEqual(get_percentage(doc, getdate("2026-04-01"), 1), 0)
def test_periodwise_distribution_maps_each_period(self):
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
doc.insert()
period_list = [
frappe._dict(key="q1", from_date=getdate("2026-01-01")),
frappe._dict(key="q2", from_date=getdate("2026-04-01")),
]
data = get_periodwise_distribution_data(doc.name, period_list, "Quarterly")
self.assertEqual(data["q1"], 100) # Jan+Feb+Mar
self.assertEqual(data["q2"], 0) # Apr+May+Jun carry no allocation

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,67 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.accounts.doctype.party_link.party_link import create_party_link
from erpnext.tests.utils import ERPNextTestSuite
CUSTOMER = "_Test Customer"
SUPPLIER = "_Test Supplier"
SUPPLIER_2 = "_Test Supplier 1"
class TestPartyLink(ERPNextTestSuite):
pass
"""Party Link ties a Customer and a Supplier together as one underlying party.
validate() constrains the primary role and blocks duplicate links."""
def setUp(self):
frappe.set_user("Administrator")
def test_create_party_link_with_customer_primary(self):
link = create_party_link("Customer", CUSTOMER, SUPPLIER)
self.assertEqual(link.primary_role, "Customer")
self.assertEqual(link.secondary_role, "Supplier")
self.assertEqual(link.primary_party, CUSTOMER)
self.assertEqual(link.secondary_party, SUPPLIER)
self.assertTrue(frappe.db.exists("Party Link", link.name))
def test_create_party_link_with_supplier_primary(self):
link = create_party_link("Supplier", SUPPLIER, CUSTOMER)
self.assertEqual(link.primary_role, "Supplier")
self.assertEqual(link.secondary_role, "Customer")
self.assertEqual(link.primary_party, SUPPLIER)
self.assertEqual(link.secondary_party, CUSTOMER)
self.assertTrue(frappe.db.exists("Party Link", link.name))
def test_primary_role_must_be_customer_or_supplier(self):
doc = frappe.new_doc("Party Link")
doc.primary_role = "Employee"
doc.primary_party = CUSTOMER
doc.secondary_role = "Supplier"
doc.secondary_party = SUPPLIER
# validate() alone isolates the role rule from the dynamic-link checks
self.assertRaises(frappe.ValidationError, doc.validate)
def test_duplicate_link_throws(self):
create_party_link("Customer", CUSTOMER, SUPPLIER)
dup = frappe.new_doc("Party Link")
dup.primary_role = "Customer"
dup.primary_party = CUSTOMER
dup.secondary_role = "Supplier"
dup.secondary_party = SUPPLIER
self.assertRaises(frappe.ValidationError, dup.insert)
def test_party_can_wrongly_be_primary_in_two_links(self):
# SUSPECTED BUG: the uniqueness checks are asymmetric - a party already a
# *primary* in another link isn't blocked, so one customer can be linked to two
# different suppliers, breaking the 1:1 mapping. Locking the current (wrong)
# behaviour so a fix that blocks primary reuse trips this test.
create_party_link("Customer", CUSTOMER, SUPPLIER)
link2 = frappe.new_doc("Party Link")
link2.primary_role = "Customer"
link2.primary_party = CUSTOMER
link2.secondary_role = "Supplier"
link2.secondary_party = SUPPLIER_2
link2.insert()
self.assertTrue(frappe.db.exists("Party Link", link2.name))

View File

@@ -414,21 +414,17 @@ frappe.ui.form.on("Payment Entry", {
show_general_ledger: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(
__("Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
},
"fa fa-table"
);
frm.add_custom_button(__("Ledger"), function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
});
}
},

View File

@@ -2424,6 +2424,9 @@ def get_party_details(company: str, party_type: str, party: str, date: str, cost
if not frappe.db.exists(party_type, party):
frappe.throw(_("{0} {1} does not exist").format(_(party_type), party))
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
frappe.has_permission(party_type, ptype, party, throw=True)
party_account = get_party_account(party_type, party, company)
account_currency = get_account_currency(party_account)
_party_name = "title" if party_type == "Shareholder" else party_type.lower() + "_name"
@@ -2431,7 +2434,7 @@ def get_party_details(company: str, party_type: str, party: str, date: str, cost
if party_type in ["Customer", "Supplier"]:
party_bank_account = get_party_bank_account(party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False)
return {
"party_account": party_account,
@@ -2527,9 +2530,7 @@ def get_reference_details(
exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date)
else:
exchange_rate = 1
outstanding_amount, total_amount = get_outstanding_on_journal_entry(
reference_name, party_type, party
)
outstanding_amount, total_amount = get_outstanding_on_journal_entry(reference_name, party_type, party)
elif reference_doctype == "Payment Entry":
if reverse_payment_details := frappe.db.get_all(

View File

@@ -246,6 +246,62 @@ class TestPaymentEntry(ERPNextTestSuite):
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", pi.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0)
def test_pay_multiple_purchase_invoices_in_one_entry(self):
pi1 = make_purchase_invoice() # outstanding 250
pi2 = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi1.name, bank_account="_Test Cash - _TC")
pe.append(
"references",
{
"reference_doctype": "Purchase Invoice",
"reference_name": pi2.name,
"total_amount": pi2.grand_total,
"outstanding_amount": pi2.outstanding_amount,
"allocated_amount": pi2.outstanding_amount,
},
)
pe.paid_amount = pe.received_amount = (
pe.references[0].allocated_amount + pe.references[1].allocated_amount
)
pe.insert()
pe.submit()
self.assertEqual(pe.total_allocated_amount, 500)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi1.name, "outstanding_amount"), 0)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi2.name, "outstanding_amount"), 0)
def test_unallocated_amount_on_overpaid_purchase_payment(self):
pi = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
pe.paid_amount = pe.references[0].allocated_amount + 200 # overpay -> 200 advance
pe.received_amount = pe.paid_amount
pe.insert()
pe.submit()
self.assertEqual(pe.docstatus, 1)
self.assertEqual(pe.unallocated_amount, 200)
# end-to-end: submitting posts a balanced GL for the full paid amount (250
# settling the invoice + 200 advance)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": pe.name, "is_cancelled": 0},
fields=["debit", "credit"],
)
self.assertTrue(gl_entries, "Submitted payment produced no GL entries")
self.assertEqual(flt(sum(e.debit for e in gl_entries)), flt(sum(e.credit for e in gl_entries)))
self.assertEqual(flt(sum(e.debit for e in gl_entries)), 450)
def test_overallocation_against_purchase_invoice_throws(self):
pi = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
pe.references[0].allocated_amount += 100 # 350 > 250 outstanding
pe.paid_amount = pe.received_amount = pe.references[0].allocated_amount
self.assertRaises(frappe.ValidationError, pe.insert)
def test_payment_against_sales_invoice_to_check_status(self):
si = create_sales_invoice(
customer="_Test Customer USD",
@@ -2317,3 +2373,65 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
customer.save()
customer = customer.name
return customer
class TestPaymentEntryValidation(ERPNextTestSuite):
"""Field-level validations invoked on the document directly, covering branches the
integration suite above doesn't reach (no GL / reconciliation setup needed)."""
def make_pe(self, **fields):
doc = frappe.new_doc("Payment Entry")
doc.update(fields)
return doc
def test_payment_type_must_be_a_known_value(self):
self.assertRaises(frappe.ValidationError, self.make_pe(payment_type="Foo").validate_payment_type)
self.make_pe(payment_type="Receive").validate_payment_type() # valid value passes
def test_nonexistent_party_is_rejected(self):
doc = self.make_pe(party_type="Customer", party="__No Such Customer__")
self.assertRaises(frappe.ValidationError, doc.validate_party_details)
def test_amount_and_exchange_rate_fields_are_mandatory(self):
# every field but target_exchange_rate is set, so that missing one raises
doc = self.make_pe(
paid_amount=100, received_amount=100, source_exchange_rate=1, target_exchange_rate=0
)
self.assertRaises(frappe.ValidationError, doc.validate_mandatory)
def test_received_amount_cannot_exceed_paid_in_same_currency(self):
doc = self.make_pe(
paid_from_account_currency="INR",
paid_to_account_currency="INR",
paid_amount=100,
received_amount=150,
)
self.assertRaises(frappe.ValidationError, doc.validate_received_amount)
# received <= paid is fine
doc.received_amount = 50
doc.validate_received_amount()
def test_duplicate_reference_rows_are_rejected(self):
doc = self.make_pe()
for _ in range(2):
doc.append(
"references",
{"reference_doctype": "Sales Invoice", "reference_name": "SI-X", "allocated_amount": 100},
)
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_entry)
def test_receive_from_customer_against_negative_outstanding_is_rejected(self):
doc = self.make_pe(party_type="Customer", payment_type="Receive")
doc.append(
"references",
{"reference_doctype": "Sales Invoice", "reference_name": "SI-Y", "allocated_amount": -100},
)
self.assertRaises(frappe.ValidationError, doc.validate_payment_type_with_outstanding)
def test_bank_transaction_requires_a_reference_number(self):
doc = self.make_pe(payment_type="Pay", paid_from="_Test Bank - _TC")
self.assertRaises(frappe.ValidationError, doc.validate_transaction_reference)
# supplying the reference details clears the requirement
doc.reference_no = "TXN-1"
doc.reference_date = "2026-06-15"
doc.validate_transaction_reference()

View File

@@ -75,7 +75,10 @@ class PaymentReconciliation(Document):
self.accounting_dimension_filter_conditions = []
self.ple_posting_date_filter = []
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
self.user_permissions = get_user_permissions(frappe.session.user)
@property
def user_permissions(self):
return get_user_permissions(frappe.session.user)
def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly.
@@ -833,10 +836,17 @@ class PaymentReconciliation(Document):
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
)
for inv in dr_cr_notes:
if (
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
< inv.allocated_amount
flt(
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
- inv.allocated_amount,
allocated_amount_precision,
)
< 0
):
frappe.throw(
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)

View File

@@ -48,6 +48,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
sinv = create_sales_invoice(
qty=qty,
rate=rate,
posting_date=posting_date,
company=self.company,
customer=self.customer,
item_code=self.item,
@@ -2110,7 +2111,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
pr.reconcile()
si.reload()
self.assertEqual(si.status, "Partly Paid")
self.assertEqual(si.status, "Overdue")
# check PR tool output post reconciliation
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
@@ -2506,6 +2507,76 @@ class TestPaymentReconciliation(ERPNextTestSuite):
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
pr.reconcile()
def test_cr_note_split_across_invoices_floating_point_precision(self):
"""Regression: when a credit note is split across multiple invoices, floating-point
arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail.
The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0),
so the invoices are created with round-number totals (100, 200, 100) and then partially paid
down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries.
"""
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
# Create invoices on different posting dates to control sort-order in Payment Reconciliation
# (invoices are sorted by posting_date ascending, so si_a is processed first).
# Processing order 8.45 → 90.72 → 72.57 produces the float chain:
# 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001
# The last allocation row will therefore carry allocated_amount = 50.83000000000001.
si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2))
si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1))
si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate())
# Partially pay each invoice so the remaining outstanding is a clean decimal value.
# INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings
# by subtracting a decimal-valued payment from the integer total:
# 100 - 91.55 = 8.45
# 200 - 109.28 = 90.72
# 100 - 27.43 = 72.57
for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)):
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = partial_paid
pe.received_amount = partial_paid
pe.references[0].allocated_amount = partial_paid
pe.save().submit()
cr_note = self.create_sales_invoice(
qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
cr_note.is_return = 1
cr_note = cr_note.save().submit()
pr = self.create_payment_reconciliation()
# Widen date range so all three invoices (oldest is -2 days) are fetched
pr.from_invoice_date = add_days(nowdate(), -2)
pr.to_invoice_date = nowdate()
pr.from_payment_date = nowdate()
pr.to_payment_date = nowdate()
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 3)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c
self.assertEqual(len(pr.allocation), 3)
last_row = pr.allocation[-1]
# Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic)
self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2)
# reconcile() must not raise "has been modified after you pulled it" due to float imprecision
pr.reconcile()
si_a.reload()
si_b.reload()
si_c.reload()
self.assertEqual(si_a.outstanding_amount, 0)
self.assertEqual(si_b.outstanding_amount, 0)
# si_c is only partially settled: 72.57 - 50.83 = 21.74
self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2)
def create_fiscal_year(company, year_start_date, year_end_date):
fy_docname = frappe.db.exists(

View File

@@ -542,6 +542,7 @@ class PaymentRequest(Document):
bank_amount=bank_amount,
created_from_payment_request=True,
)
payment_entry.set_missing_ref_details(force=True)
payment_entry.update(
{
@@ -718,7 +719,7 @@ class PaymentRequest(Document):
row_number += TO_SKIP_NEW_ROW
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def make_payment_request(**args):
"""Make payment request"""

View File

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

View File

@@ -41,21 +41,17 @@ frappe.ui.form.on("Period Closing Voucher", {
refresh: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(
__("Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
},
"fa fa-table"
);
frm.add_custom_button(__("Ledger"), function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
});
}
},
});

View File

@@ -315,6 +315,77 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
repost_doc.posting_date = today()
repost_doc.save()
def test_dimension_grouped_opening_balance_matches_gl_scan(self):
"""
A dimension-grouped Balance Sheet must produce identical per-dimension
figures whether opening balances come from
- Account Closing Balance (the fast path) or
- from a full GL scan (the fallback).
"""
from frappe.utils import add_days, getdate
from erpnext.accounts.report.balance_sheet.balance_sheet import execute
from erpnext.accounts.report.financial_statements import build_period_list
company = "Test PCV Company"
cc1 = create_cost_center("Test Cost Center 1")
cc2 = create_cost_center("Test Cost Center 2")
# Post to two cost centers, then close the year so balances land in Account Closing Balance.
for amount, cost_center in ((400, cc1), (200, cc2)):
jv = make_journal_entry(
posting_date="2021-03-15",
amount=amount,
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
save=False,
)
jv.company = company
jv.save()
jv.submit()
pcv = self.make_period_closing_voucher(posting_date="2021-03-31")
report_date = add_days(getdate(pcv.period_end_date), 1)
report_filters = frappe._dict(
company=company,
period_start_date=report_date,
period_end_date=report_date,
periodicity="Yearly",
filter_based_on="Date Range",
accumulated_values=True,
group_by_dimension="Cost Center",
)
period_list = build_period_list(report_filters)
period_keys = [p.key for p in period_list]
def key_for(cost_center):
return next(p.key for p in period_list if p.dimension_value == cost_center)
def figures(data):
return {
row["account_name"]: {k: row.get(k) for k in period_keys}
for row in data
if row.get("account_name")
}
# Fast path: opening balance sourced from Account Closing Balance.
acb_figures = figures(execute(report_filters)[1])
# Fallback: force a full GL scan and expect the same numbers.
with self.change_settings("Accounts Settings", {"ignore_account_closing_balance": 1}):
gl_figures = figures(execute(report_filters)[1])
self.assertEqual(acb_figures, gl_figures)
# the fast path must carry per-dimension opening balances, not aggregates or zeros
self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400)
self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200)
def make_period_closing_voucher(self, posting_date, submit=True):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")

View File

@@ -0,0 +1,34 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# Regression test for https://github.com/frappe/erpnext/issues/56501
# AttributeError: 'POSInvoice' object has no attribute 'is_created_using_pos'
# when calling reset_mode_of_payments on a draft POS Invoice.
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import (
POSInvoiceTestMixin,
create_pos_invoice,
)
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
class TestPOSInvoiceResetModeOfPayments(POSInvoiceTestMixin):
def setUp(self):
super().setUp()
create_opening_entry(self.pos_profile, self.test_user.name)
def test_reset_mode_of_payments_does_not_raise_attribute_error(self):
"""Calling reset_mode_of_payments on a draft POS Invoice must not raise
AttributeError for the missing is_created_using_pos attribute.
update_multi_mode_option accesses doc.is_created_using_pos, which is a
field on SalesInvoice but does not exist on POSInvoice, causing the error
reported in #56501 when a user tries to edit a saved draft order.
"""
inv = create_pos_invoice(do_not_submit=True)
# This call must not raise AttributeError on the missing field.
inv.reset_mode_of_payments()
# Payments should have been repopulated from the POS profile.
self.assertTrue(len(inv.payments) > 0, "Payments should be populated after reset")

View File

@@ -40,7 +40,7 @@ frappe.ui.form.on("Pricing Rule", {
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
${__("Notes")}
</h4>
<ul>
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
</ul>
</td></tr>
<tr><td>
<h4><i class="fa fa-question-sign"></i>
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
${__("How Pricing Rule is applied?")}
</h4>
<ol>

View File

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

View File

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

View File

@@ -106,6 +106,8 @@ def get_pr_instance(doc: str):
"party",
"receivable_payable_account",
"default_advance_account",
"bank_cash_account",
"cost_center",
"from_invoice_date",
"to_invoice_date",
"from_payment_date",

View File

@@ -1,11 +1,73 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
get_pr_instance,
)
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestProcessPaymentReconciliation(ERPNextTestSuite):
pass
"""Process Payment Reconciliation validates its accounts against the company,
moves to Queued on submit, and hands its filters to a Payment Reconciliation run."""
def setUp(self):
frappe.set_user("Administrator")
def make_ppr(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Process Payment Reconciliation")
doc.company = COMPANY
doc.party_type = "Customer"
doc.party = "_Test Customer"
doc.receivable_payable_account = args.get("receivable_payable_account", "Debtors - _TC")
doc.bank_cash_account = args.get("bank_cash_account")
doc.from_invoice_date = args.get("from_invoice_date")
doc.to_invoice_date = args.get("to_invoice_date")
return doc
def other_company_account(self, **extra):
filters = {"company": "_Test Company 1", "is_group": 0, **extra}
account = frappe.db.get_value("Account", filters, "name")
self.assertTrue(account, "need a matching account in _Test Company 1")
return account
def test_receivable_account_must_belong_to_company(self):
doc = self.make_ppr(receivable_payable_account=self.other_company_account(account_type="Receivable"))
self.assertRaises(frappe.ValidationError, doc.insert)
def test_bank_cash_account_must_belong_to_company(self):
doc = self.make_ppr(bank_cash_account=self.other_company_account())
self.assertRaises(frappe.ValidationError, doc.insert)
def test_submit_sets_status_to_queued(self):
doc = self.make_ppr()
doc.insert()
doc.submit()
self.assertEqual(doc.status, "Queued")
def test_get_pr_instance_copies_filters_and_caps_limits(self):
doc = self.make_ppr(from_invoice_date="2026-01-01", to_invoice_date="2026-06-30")
doc.insert()
pr = get_pr_instance(doc.name)
self.assertEqual(pr.company, COMPANY)
self.assertEqual(pr.party, "_Test Customer")
self.assertEqual(pr.receivable_payable_account, "Debtors - _TC")
self.assertEqual(str(pr.from_invoice_date), "2026-01-01")
# the tool run is capped so a single process can't fetch unbounded rows
self.assertEqual(pr.invoice_limit, 1000)
self.assertEqual(pr.payment_limit, 1000)
def test_get_pr_instance_copies_bank_cash_and_cost_center(self):
doc = self.make_ppr(bank_cash_account="Cash - _TC")
doc.cost_center = "_Test Cost Center - _TC"
doc.insert()
pr = get_pr_instance(doc.name)
self.assertEqual(pr.bank_cash_account, "Cash - _TC")
self.assertEqual(pr.cost_center, "_Test Cost Center - _TC")

View File

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

View File

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

View File

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

View File

@@ -100,9 +100,9 @@ class ProcessStatementOfAccounts(Document):
if not self.pdf_name:
self.pdf_name = "{{ customer.customer_name }}"
validate_template(self.subject)
validate_template(self.body)
validate_template(self.pdf_name)
validate_template(self.subject, restrict_globals=True)
validate_template(self.body, restrict_globals=True)
validate_template(self.pdf_name, restrict_globals=True)
if not self.customers:
frappe.throw(_("Customers not selected."))
@@ -421,7 +421,6 @@ def get_context(customer, doc):
return {
"doc": template_doc,
"customer": frappe.get_doc("Customer", customer),
"frappe": frappe.utils,
}
@@ -532,15 +531,15 @@ def send_emails(document_name: str, from_scheduler: bool = False, posting_date:
if report:
for customer, report_pdf in report.items():
context = get_context(customer, doc)
filename = frappe.render_template(doc.pdf_name, context)
filename = frappe.render_template(doc.pdf_name, context, restrict_globals=True)
attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}]
recipients, cc = get_recipients_and_cc(customer, doc)
if not recipients:
continue
subject = frappe.render_template(doc.subject, context)
message = frappe.render_template(doc.body, context)
subject = frappe.render_template(doc.subject, context, restrict_globals=True)
message = frappe.render_template(doc.body, context, restrict_globals=True)
if doc.sender:
sender_email = frappe.db.get_value("Email Account", doc.sender, "email_id")

View File

@@ -113,3 +113,38 @@ def create_process_soa(**args):
process_soa.update(soa_dict)
process_soa.save()
return process_soa
class TestProcessStatementOfAccountsValidation(ERPNextTestSuite):
"""validate() fills in default subject/body/pdf templates and enforces the
basic constraints. Exercised on the document directly (no email/PDF flow)."""
def make_soa(self, report="Accounts Receivable", with_customer=True, **overrides):
doc = frappe.new_doc("Process Statement Of Accounts")
doc.report = report
doc.company = "_Test Company"
if with_customer:
doc.append("customers", {"customer": "_Test Customer"})
doc.update(overrides)
return doc
def test_customers_are_required(self):
self.assertRaises(frappe.ValidationError, self.make_soa(with_customer=False).validate)
def test_general_ledger_body_uses_a_date_range(self):
doc = self.make_soa(report="General Ledger")
doc.validate()
self.assertIn("from {{ doc.from_date }} to {{ doc.to_date }}", doc.body)
# subject and pdf name are also defaulted
self.assertTrue(doc.subject)
self.assertTrue(doc.pdf_name)
def test_receivable_body_uses_the_posting_date(self):
doc = self.make_soa(report="Accounts Receivable")
doc.validate()
self.assertIn("until {{ doc.posting_date }}", doc.body)
def test_account_must_belong_to_company(self):
other = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other, "need an account in _Test Company 1")
self.assertRaises(frappe.ValidationError, self.make_soa(account=other).validate)

View File

@@ -1,11 +1,56 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from unittest.mock import patch
import frappe
from erpnext.accounts.doctype.process_subscription.process_subscription import (
create_subscription_process,
)
from erpnext.accounts.doctype.subscription.test_subscription import (
create_parties,
create_subscription,
make_plans,
reset_settings,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessSubscription(ERPNextTestSuite):
pass
"""Process Subscription is a batch driver: on submit it enqueues subscription.process_all
for every non-cancelled Subscription (or just one when a subscription is named)."""
def setUp(self):
frappe.set_user("Administrator")
# mirror TestSubscription setup so subscriptions build against known settings
make_plans()
create_parties()
reset_settings()
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
def enqueued_subscriptions(self, subscription=None):
"""Submit a Process Subscription while capturing what gets enqueued."""
calls = []
def capture(*args, **kwargs):
calls.append(kwargs)
with patch("frappe.enqueue", side_effect=capture):
create_subscription_process(subscription=subscription, posting_date="2026-06-15")
# each enqueue is handed a batch (list) of subscription names
return [name for call in calls for name in call.get("subscription", [])]
def test_named_subscription_is_the_only_one_enqueued(self):
sub = create_subscription(start_date="2026-01-01")
self.assertEqual(self.enqueued_subscriptions(subscription=sub.name), [sub.name])
def test_cancelled_subscriptions_are_skipped(self):
active = create_subscription(start_date="2026-01-01")
cancelled = create_subscription(start_date="2026-01-01")
cancelled.cancel_subscription()
enqueued = self.enqueued_subscriptions()
self.assertIn(active.name, enqueued)
self.assertNotIn(cancelled.name, enqueued)

View File

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

View File

@@ -3,7 +3,6 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form
import erpnext
@@ -131,7 +130,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
get_purchase_document_details,
)
from erpnext.stock.utils import get_valuation_method
doc = self.doc
tax_service = TaxService(doc)
@@ -331,33 +329,25 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
self.make_provisional_gl_entry(gl_entries, item)
if not doc.is_internal_transfer():
handled = False
if (
item.item_code
and item.item_code in stock_items
and item.get("purchase_receipt")
and not doc.is_return
and get_valuation_method(item.item_code, doc.company) == "Standard Cost"
):
handled = self.make_standard_cost_srbnb_split(
gl_entries, item, expense_account, account_currency, base_amount
)
if not handled:
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": base_amount,
"debit_in_transaction_currency": amount,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
# When Update Stock is disabled, this invoice has no stock impact: the linked
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
# amount against the supplier - booking PPV again would double count it and leave
# SRBNB partially uncleared.
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": base_amount,
"debit_in_transaction_currency": amount,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
# check if the exchange rate has changed
if (
@@ -530,95 +520,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
},
)
def make_standard_cost_srbnb_split(
self, gl_entries, item, expense_account, account_currency, base_amount
):
"""For a Standard Cost item billed against a Purchase Receipt, clear SRBNB at the standard
value the receipt actually booked and post the (Net Amount - standard) difference to the
Purchase Price Variance account. Returns False (caller falls back) if the receipt value
can't be resolved."""
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_purchase_price_variance_account,
)
doc = self.doc
precision = item.precision("base_net_amount")
standard_value = flt(self.get_pr_stock_value(item), precision)
if not standard_value:
return False
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": standard_value,
"debit_in_transaction_currency": flt(standard_value / doc.conversion_rate, precision),
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
variance = flt(base_amount - standard_value, precision)
if variance:
gl_entries.append(
self.get_gl_dict(
{
"account": get_purchase_price_variance_account(item.item_code, doc.company),
"against": doc.supplier,
"debit": variance,
"debit_in_transaction_currency": flt(variance / doc.conversion_rate, precision),
"remarks": doc.get("remarks") or _("Purchase Price Variance"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
item=item,
)
)
return True
def get_pr_stock_value(self, item):
"""Stock value (at standard) the linked Purchase Receipt booked for the quantity this invoice
row is billing.
Accepted and rejected stock for the same receipt row share `voucher_detail_no`, so the
warehouse filter is required: without it the accepted warehouse's SRBNB would be cleared at
accepted + rejected value and post the wrong Purchase Price Variance amount. The accepted
warehouse is read from the receipt row itself (not the invoice row, which may be unset on a
non-stock invoice).
The receipt's full accepted value is pro-rated to the invoiced quantity, so a partial bill
clears SRBNB (and posts PPV) for only the units it covers, not the whole receipt row."""
pr_detail = frappe.db.get_value(
"Purchase Receipt Item", item.pr_detail, ["warehouse", "stock_qty"], as_dict=True
)
if not pr_detail or not pr_detail.warehouse:
return 0.0
sle = frappe.qb.DocType("Stock Ledger Entry")
result = (
frappe.qb.from_(sle)
.select(Sum(sle.stock_value_difference))
.where(
(sle.voucher_type == "Purchase Receipt")
& (sle.voucher_no == item.purchase_receipt)
& (sle.voucher_detail_no == item.pr_detail)
& (sle.warehouse == pr_detail.warehouse)
& (sle.is_cancelled == 0)
)
).run()
accepted_value = flt(result[0][0]) if result and result[0][0] else 0.0
if not accepted_value or not flt(pr_detail.stock_qty):
return accepted_value
# Pro-rate to the quantity being billed by this invoice row (handles partial billing).
return accepted_value * flt(item.stock_qty) / flt(pr_detail.stock_qty)
def get_stock_variance_account(self, item):
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
Variance; for all other items it keeps the existing behaviour (default expense account)."""

View File

@@ -472,7 +472,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pr = frappe.new_doc("Purchase Receipt")
pr.currency = "USD"
pr.company = "_Test Company with perpetual inventory"
pr.conversion_rate = (70,)
pr.conversion_rate = 80
pr.supplier = "_Test Supplier USD"
pr.append(
"items",
@@ -491,7 +491,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# Createing purchase invoice against Purchase Receipt
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 80
pi.conversion_rate = 70
pi.credit_to = "_Test Payable USD - TCP1"
pi.insert()
pi.submit()

View File

@@ -1,11 +1,55 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestRepostPaymentLedger(ERPNextTestSuite):
pass
"""Repost Payment Ledger auto-selects submitted vouchers on/after a cutoff date
(unless rows are added manually) and queues them for a ledger rebuild."""
def setUp(self):
frappe.set_user("Administrator")
def make_repost(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Repost Payment Ledger")
doc.company = COMPANY
doc.posting_date = args.get("posting_date", "2026-06-01")
doc.voucher_type = args.get("voucher_type", "Sales Invoice")
doc.add_manually = args.get("add_manually", 0)
return doc
def test_loads_submitted_vouchers_on_or_after_cutoff(self):
after_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
on_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-01", rate=100, qty=1)
before_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
doc = self.make_repost(posting_date="2026-06-01", voucher_type="Sales Invoice")
doc.save() # before_validate loads the vouchers and sets status
loaded = {v.voucher_no for v in doc.repost_vouchers}
self.assertIn(after_cutoff.name, loaded)
# the filter is >= so an invoice posted exactly on the cutoff is included
self.assertIn(on_cutoff.name, loaded)
self.assertNotIn(before_cutoff.name, loaded)
self.assertEqual(doc.repost_status, "Queued")
def test_add_manually_preserves_user_rows(self):
# manually add a BEFORE-cutoff invoice (which the filter would never load) while a
# matching after-cutoff invoice also exists. If auto-loading wrongly ran it would
# drop the manual row and pull the after-cutoff one, so this distinguishes the modes.
manual_si = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
doc = self.make_repost(add_manually=1, posting_date="2026-06-01")
doc.append("repost_vouchers", {"voucher_type": "Sales Invoice", "voucher_no": manual_si.name})
doc.save()
rows = [(v.voucher_type, v.voucher_no) for v in doc.repost_vouchers]
self.assertEqual(rows, [("Sales Invoice", manual_si.name)])

View File

@@ -571,8 +571,6 @@ def create_dunning(
source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False
):
def postprocess_dunning(source, target):
from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
if dunning_type:
dunning_type = frappe.get_doc("Dunning Type", dunning_type)
@@ -581,20 +579,22 @@ def create_dunning(
target.dunning_fee = dunning_type.dunning_fee
target.income_account = dunning_type.income_account
target.cost_center = dunning_type.cost_center
letter_text = get_dunning_letter_text(
dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
)
if letter_text:
target.body_text = letter_text.get("body_text")
target.closing_text = letter_text.get("closing_text")
target.language = letter_text.get("language")
target.language = source.language
target.get_dunning_letter_text()
# update outstanding from doc
if source.payment_schedule and len(source.payment_schedule) == 1:
for row in target.overdue_payments:
if row.payment_schedule == source.payment_schedule[0].name:
row.outstanding = source.get("outstanding_amount")
# outstanding_amount is in the party account currency, but the Overdue Payment
# row is in the invoice's transaction currency. When they differ, use the
# payment schedule's own outstanding — it is kept in transaction currency and
# updated as payments are allocated, so it stays correct even when the invoice
# and its payments post at different exchange rates (#56006).
if source.party_account_currency and source.party_account_currency != source.currency:
row.outstanding = source.payment_schedule[0].outstanding
else:
row.outstanding = source.get("outstanding_amount")
target.validate()

View File

@@ -465,6 +465,7 @@ class SalesInvoice(SellingController):
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
self.check_credit_limit()
self.check_overdue_billing_threshold()
if cint(self.is_pos) != 1 and not self.is_return:
self.update_against_document_in_jv()
@@ -669,6 +670,11 @@ class SalesInvoice(SellingController):
if validate_against_credit_limit:
check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order)
def check_overdue_billing_threshold(self):
from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold
check_overdue_billing_threshold(self.customer, self.company)
@frappe.whitelist()
def set_missing_values(self, for_validate: bool = False):
pos = POSService(self).set_pos_fields(for_validate)

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

@@ -344,7 +344,9 @@ def update_multi_mode_option(doc, pos_profile) -> None:
payment.account = payment_mode.default_account
payment.type = payment_mode.type
mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
# is_created_using_pos exists on Sales Invoice but not POS Invoice; use get() so this
# shared helper doesn't raise AttributeError when called on a POS Invoice
mop_refetched = bool(doc.payments) and not doc.get("is_created_using_pos")
doc.set("payments", [])
invalid_modes = []

View File

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

View File

@@ -121,3 +121,65 @@ class TestShareTransfer(ERPNextTestSuite):
}
)
self.assertRaises(ShareDontExists, doc.insert)
class TestShareTransferValidation(ERPNextTestSuite):
"""basic_validations() enforces the transfer's internal consistency. Exercised
directly (to_folio_no set to skip folio auto-naming) so no shareholder fixtures
are needed - it only reasons about the document's own fields."""
def make_transfer(self, **overrides):
doc = frappe.new_doc("Share Transfer")
doc.update(
{
"transfer_type": "Transfer",
"date": "2026-01-01",
"from_shareholder": "SH-A",
"to_shareholder": "SH-B",
"to_folio_no": "1",
"share_type": "Equity",
"from_no": 1,
"to_no": 100,
"no_of_shares": 100,
"rate": 10,
"amount": 1000,
"company": "_Test Company",
"equity_or_liability_account": "Creditors - _TC",
}
)
doc.update(overrides)
return doc
def test_baseline_transfer_is_consistent(self):
# the helper's defaults must pass, otherwise the negative cases prove nothing
self.make_transfer().basic_validations()
def test_seller_and_buyer_must_differ(self):
doc = self.make_transfer(to_shareholder="SH-A")
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_share_count_must_match_the_number_range(self):
# 1..100 is 100 shares, not 50
doc = self.make_transfer(no_of_shares=50)
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_amount_must_equal_rate_times_shares(self):
doc = self.make_transfer(amount=999) # 10 * 100 = 1000
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_amount_is_derived_when_left_blank(self):
doc = self.make_transfer(amount=0)
doc.basic_validations()
self.assertEqual(doc.amount, 1000)
def test_equity_or_liability_account_is_required(self):
doc = self.make_transfer(equity_or_liability_account=None)
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_issue_requires_a_to_shareholder(self):
doc = self.make_transfer(transfer_type="Issue", to_shareholder="", asset_account="Cash - _TC")
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_purchase_requires_a_from_shareholder(self):
doc = self.make_transfer(transfer_type="Purchase", from_shareholder="", asset_account="Cash - _TC")
self.assertRaises(frappe.ValidationError, doc.basic_validations)

View File

@@ -79,7 +79,9 @@ def get_plan_rate(
start_date = getdate(start_date)
end_date = getdate(end_date)
no_of_months = relativedelta.relativedelta(end_date, start_date).months + 1
delta = relativedelta.relativedelta(end_date, start_date)
# include the years component so cross-year spans aren't under-counted
no_of_months = delta.years * 12 + delta.months + 1
cost = plan.cost * no_of_months
# Adjust cost if start or end date is not month start or end

View File

@@ -1,8 +1,54 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.tests.utils import ERPNextTestSuite
class TestSubscriptionPlan(ERPNextTestSuite):
pass
"""Subscription Plan validates its interval and computes a rate. The Monthly
Rate branch multiplies cost by the number of months in the billing window."""
def setUp(self):
frappe.set_user("Administrator")
def make_plan(self, **args):
args = frappe._dict(args)
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = f"_Test Plan {frappe.generate_hash(length=6)}"
plan.item = args.item or "_Test Item"
plan.currency = args.currency or "INR"
plan.price_determination = args.price_determination
plan.cost = args.cost or 0
plan.billing_interval = args.billing_interval or "Month"
plan.billing_interval_count = (
args.billing_interval_count if args.billing_interval_count is not None else 1
)
return plan
def test_billing_interval_count_must_be_positive(self):
plan = self.make_plan(price_determination="Fixed Rate", cost=100, billing_interval_count=0)
self.assertRaises(frappe.ValidationError, plan.insert)
def test_fixed_rate_applies_prorate_factor(self):
plan = self.make_plan(price_determination="Fixed Rate", cost=100)
plan.insert()
self.assertEqual(get_plan_rate(plan.name), 100)
self.assertEqual(get_plan_rate(plan.name, prorate_factor=0.5), 50)
def test_monthly_rate_within_year(self):
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
plan.insert()
# Jan 1 - Mar 31 is 3 whole months; month-aligned so proration is 0
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2026-03-31")
self.assertEqual(rate, 300)
def test_monthly_rate_across_year_boundary(self):
# a 14-month span (Jan 2026 to Feb 2027) bills all 14 months, not just the
# 2-month remainder that relativedelta.months alone would give
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
plan.insert()
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2027-02-28")
self.assertEqual(rate, 1400)

View File

@@ -0,0 +1,24 @@
{
"applies_to_doctype": "Party Account",
"creation": "2026-07-09 16:13:10.010246",
"docstatus": 0,
"doctype": "DocType Settings Map",
"idx": 0,
"is_active": 1,
"is_standard": 1,
"mappings": [
{
"setting_field": "enable_common_party_accounting",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "allow_multi_currency_invoices_against_single_party_account",
"settings_doctype": "Accounts Settings"
}
],
"modified": "2026-07-09 16:13:49.623613",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Party Account (Standard)",
"owner": "Administrator"
}

View File

@@ -0,0 +1,32 @@
{
"applies_to_doctype": "Payment Entry",
"creation": "2026-07-09 15:13:39.598717",
"docstatus": 0,
"doctype": "DocType Settings Map",
"idx": 0,
"is_active": 1,
"is_standard": 1,
"mappings": [
{
"setting_field": "unlink_payment_on_cancellation_of_invoice",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "book_tax_discount_loss",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "over_billing_allowance",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "merge_similar_account_heads",
"settings_doctype": "Accounts Settings"
}
],
"modified": "2026-07-10 11:26:57.841200",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry (Standard)",
"owner": "Administrator"
}

View File

@@ -0,0 +1,20 @@
{
"applies_to_doctype": "POS Invoice",
"creation": "2026-07-03 13:02:14.089430",
"docstatus": 0,
"doctype": "DocType Settings Map",
"idx": 0,
"is_active": 1,
"is_standard": 1,
"mappings": [
{
"setting_field": "enable_utm",
"settings_doctype": "Selling Settings"
}
],
"modified": "2026-07-03 13:02:14.089430",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice (Standard)",
"owner": "Administrator"
}

View File

@@ -0,0 +1,88 @@
{
"applies_to_doctype": "Purchase Invoice",
"creation": "2026-07-03 14:20:03.649461",
"docstatus": 0,
"doctype": "DocType Settings Map",
"idx": 0,
"is_active": 1,
"is_standard": 1,
"mappings": [
{
"setting_field": "allow_to_edit_stock_uom_qty_for_purchase",
"settings_doctype": "Stock Settings"
},
{
"setting_field": "allow_to_make_quality_inspection_after_purchase_or_delivery",
"settings_doctype": "Stock Settings"
},
{
"setting_field": "maintain_same_rate",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "maintain_same_rate_action",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "role_to_override_stop_action",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "bill_for_rejected_quantity_in_purchase_invoice",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "use_transaction_date_exchange_rate",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "allow_multiple_items",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "disable_last_purchase_rate",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "unlink_payment_on_cancellation_of_invoice",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "check_supplier_invoice_uniqueness",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "automatically_fetch_payment_terms",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "po_required",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "project_update_frequency",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "pr_required",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "set_landed_cost_based_on_purchase_invoice_rate",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "over_billing_allowance",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "role_allowed_to_over_bill",
"settings_doctype": "Accounts Settings"
}
],
"modified": "2026-07-10 11:25:15.824417",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice (Standard)",
"owner": "Administrator"
}

View File

@@ -0,0 +1,112 @@
{
"applies_to_doctype": "Sales Invoice",
"creation": "2026-06-30 15:53:13.817029",
"docstatus": 0,
"doctype": "DocType Settings Map",
"idx": 0,
"is_active": 1,
"is_standard": 1,
"mappings": [
{
"setting_field": "editable_price_list_rate",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "maintain_same_sales_rate",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "maintain_same_rate_action",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "role_to_override_stop_action",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "validate_selling_price",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "allow_negative_rates_for_items",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "sales_update_frequency",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "allow_multiple_items",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "hide_tax_id",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "enable_discount_accounting",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "enable_utm",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "allow_to_edit_stock_uom_qty_for_sales",
"settings_doctype": "Stock Settings"
},
{
"setting_field": "show_barcode_field",
"settings_doctype": "Stock Settings"
},
{
"setting_field": "allow_to_make_quality_inspection_after_purchase_or_delivery",
"settings_doctype": "Stock Settings"
},
{
"setting_field": "unlink_payment_on_cancellation_of_invoice",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "automatically_fetch_payment_terms",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "dn_required",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "so_required",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "fetch_timesheet_in_sales_invoice",
"settings_doctype": "Projects Settings"
},
{
"setting_field": "set_zero_rate_for_expired_batch",
"settings_doctype": "Selling Settings"
},
{
"setting_field": "invoice_type",
"settings_doctype": "POS Settings"
},
{
"setting_field": "post_change_gl_entries",
"settings_doctype": "POS Settings"
},
{
"setting_field": "over_billing_allowance",
"settings_doctype": "Accounts Settings"
},
{
"setting_field": "role_allowed_to_over_bill",
"settings_doctype": "Accounts Settings"
}
],
"modified": "2026-07-10 11:14:25.977200",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice (Standard)",
"owner": "Administrator"
}

View File

@@ -0,0 +1,24 @@
{
"applies_to_doctype": "Subscription",
"creation": "2026-07-09 15:08:44.722645",
"docstatus": 0,
"doctype": "DocType Settings Map",
"idx": 0,
"is_active": 1,
"is_standard": 1,
"mappings": [
{
"setting_field": "grace_period",
"settings_doctype": "Subscription Settings"
},
{
"setting_field": "cancel_after_grace",
"settings_doctype": "Subscription Settings"
}
],
"modified": "2026-07-09 15:08:57.487184",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription (Standard)",
"owner": "Administrator"
}

View File

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

View File

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

View File

@@ -430,6 +430,17 @@ def get_party_account(
Will first search in party (Customer / Supplier) record, if not found,
will search in group (Customer Group / Supplier Group),
finally will return default."""
def account_perm_check(account):
ptype = "select" if frappe.only_has_select_perm("Account") else "read"
if frappe.has_permission("Account", ptype, account):
return
# Using custom message to prevent data leak in case of `apply_strict_permission` is enabled.
frappe.throw(
_("User don't have permissions to select/read this account."), exc=frappe.PermissionError
)
if not party_type:
frappe.throw(_("Party Type is mandatory"))
if not company:
@@ -440,46 +451,51 @@ def get_party_account(
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
return frappe.get_cached_value("Company", company, default_account_name)
account = frappe.db.get_value(
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
)
if not account and party_type in ["Customer", "Supplier"]:
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
account = frappe.get_cached_value("Company", company, default_account_name)
else:
account = frappe.db.get_value(
"Party Account",
{"parenttype": party_group_doctype, "parent": group, "company": company},
"account",
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
)
if not account and party_type in ["Customer", "Supplier"]:
default_account_name = (
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
account = frappe.get_cached_value("Company", company, default_account_name)
if not account and party_type in ["Customer", "Supplier"]:
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
account = frappe.db.get_value(
"Party Account",
{"parenttype": party_group_doctype, "parent": group, "company": company},
"account",
)
existing_gle_currency = get_party_gle_currency(party_type, party, company)
if existing_gle_currency:
if account:
account_currency = frappe.get_cached_value("Account", account, "account_currency")
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
if not account and party_type in ["Customer", "Supplier"]:
default_account_name = (
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
)
account = frappe.get_cached_value("Company", company, default_account_name)
# get default account on the basis of party type
if not account:
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
default_account_name = "default_" + account_type.lower() + "_account"
account = frappe.get_cached_value("Company", company, default_account_name)
existing_gle_currency = get_party_gle_currency(party_type, party, company)
if existing_gle_currency:
if account:
account_currency = frappe.get_cached_value("Account", account, "account_currency")
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
if include_advance and party_type in ["Customer", "Supplier", "Student"]:
# get default account on the basis of party type
if not account:
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
default_account_name = "default_" + account_type.lower() + "_account"
account = frappe.get_cached_value("Company", company, default_account_name)
if account:
account_perm_check(account)
if include_advance and party and party_type in ["Customer", "Supplier", "Student"]:
advance_account = get_party_advance_account(party_type, party, company)
if advance_account:
account_perm_check(advance_account)
return [account, advance_account]
else:
return [account]
return [account]
return account

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

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

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