Compare commits

...

137 Commits

Author SHA1 Message Date
mergify[bot]
ff6c8bbb44 fix(project): improved access control for project users (backport #56675) (#57180)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-15 15:38:46 +05:30
Mihir Kandoi
93931ac91b Merge pull request #57043 from aerele/fix/apply-user-permissions-via-build-match-conditions
fix: apply user permissions via build_match_conditions
2026-07-15 14:37:21 +05:30
mergify[bot]
6b23b007a4 fix: permission issue (backport #57112) (#57142)
* fix: permission issue (#57112)

(cherry picked from commit 1fd2faa68d)

# Conflicts:
#	erpnext/controllers/stock_controller.py

* chore: fix conflicts

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-15 14:34:44 +05:30
ervishnucs
202f52271c fix: apply user permissions via build_match_conditions 2026-07-15 13:16:30 +05:30
mergify[bot]
654890dce8 Revert "chore: remove unused whitelisted method from project" (backport #56660) (#57177)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-15 07:18:42 +00:00
Mihir Kandoi
218f783a26 Merge pull request #57172 from frappe/mergify/bp/version-15-hotfix/pr-57137
feat: add on hold status to project (backport #57137)
2026-07-15 11:35:07 +05:30
Poovitha Palanivelu
5e2e15436d feat: add on hold status to project
(cherry picked from commit 672fadaa78)
2026-07-15 05:47:00 +00:00
Mihir Kandoi
f179c91036 Merge pull request #57167 from frappe/mergify/bp/version-15-hotfix/pr-57164
fix: set correct currency in supplier quotation net rate field (backport #57164)
2026-07-15 11:04:57 +05:30
Mihir Kandoi
30ab2dba6e fix: set correct currency in supplier quotation net rate field
(cherry picked from commit 27672851cd)
2026-07-15 05:16:55 +00:00
Mihir Kandoi
82f2af3a0e Merge pull request #57128 from frappe/mergify/bp/version-15-hotfix/pr-57126
fix(manufacturing): preserve job card transferred quantity (backport #57126)
2026-07-14 13:59:48 +05:30
Sudharsanan11
1bbeec6bfa test(manufacturing): cover transferred quantity across job cards
(cherry picked from commit fd6c9a71cd)
2026-07-14 08:11:06 +00:00
Sudharsanan11
fc4ac7a441 fix(manufacturing): preserve job card transferred quantity
(cherry picked from commit d7e9321746)
2026-07-14 08:11:06 +00:00
Mihir Kandoi
3913b9631f Merge pull request #57104 from frappe/mergify/bp/version-15-hotfix/pr-57091
fix(stock): show qty (company) and qty (warehouse) in sales transactions  (backport #57091)
2026-07-14 12:43:29 +05:30
Mihir Kandoi
9de9228cb9 Merge pull request #57117 from frappe/mergify/bp/version-15-hotfix/pr-57116
fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos (backport #57116)
2026-07-14 12:33:51 +05:30
Afsal Syed
dcff9164c4 test(stock): add unit test for get_bundle_wise_serial_nos query
(cherry picked from commit e748bf512b)
2026-07-14 12:09:41 +05:30
Sudharsanan11
aa4cc4c97d test(stock): assert qty (company) and qty (warehouse) on item details 2026-07-14 11:51:54 +05:30
Sudharsanan11
8086542885 fix(stock): show qty (company) and qty (warehouse) in sales transactions 2026-07-14 11:51:54 +05:30
Afsal Syed
cc457433fe fix(stock): fix sqlparse token limit in get_bundle_wise_serial_nos
(cherry picked from commit 4544a6c935)
2026-07-14 05:59:56 +00:00
Khushi Rawat
687fed031b Merge pull request #57110 from khushi8112/backport-55276-to-v15-hotfix
fix(asset): allow asset repair creation for fully depreciated assets (backport #55276)
2026-07-14 00:48:06 +05:30
Khushi Rawat
e4d2a960d2 fix(asset): allow asset repair creation for fully depreciated assets (backport #55276) 2026-07-14 00:30:06 +05:30
Mihir Kandoi
ce62ef3e2e Merge pull request #57102 from frappe/mergify/bp/version-15-hotfix/pr-57089
fix: allow barcode scan to add and increment items in pick list (backport #57089)
2026-07-13 21:28:03 +05:30
pandiyan
bb5be10372 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

(cherry picked from commit 3ece4a615d)
2026-07-13 15:32:46 +00:00
ruthra kumar
fcd0350652 refactor: rename synced to snapshot report (backport #56706) (#57094)
* refactor: rename execute_synced_report to execute_snapshot_report

Match the framework rename of the standard report entry point in the
trial balance, P&L, balance sheet, and general ledger reports.

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

* refactor: rename feature toggle in report master

(cherry picked from commit 981e90e4da)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
#	erpnext/accounts/report/trial_balance/trial_balance.json

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:36:35 +05:30
ruthra kumar
1489258ea0 refactor: rename feature toggle in report master
(cherry picked from commit 981e90e4da)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
#	erpnext/accounts/report/trial_balance/trial_balance.json
2026-07-13 16:08:50 +05:30
ruthra kumar
674704cbfd refactor: rename execute_synced_report to execute_snapshot_report
Match the framework rename of the standard report entry point in the
trial balance, P&L, balance sheet, and general ledger reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ba7b6a47c5)
2026-07-13 10:35:28 +00:00
mergify[bot]
a6a887f833 feat: faster (synced) financial statements using duckdb (backport #56304) (#57093)
* refactor: reports on duckdb

(cherry picked from commit adb768505a)

* feat(trial-balance): implement execute_duckdb with full parity to normal report

Replaces the placeholder stub with 8 focused functions that mirror the
normal execute() flow using parameterized DuckDB SQL queries: account
fetch, period GL entries, opening balances (with Period Closing Voucher
path), and all filters (cost center, project, finance book, accounting
dimensions). Reuses existing pure-Python processing functions unchanged.

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

* refactor(trial-balance): execute_duckdb only reads GL Entry from duckdb

Replaces the previous over-engineered stub with 7 short functions.
Account data, Account Closing Balance, and all metadata come from
frappe.db as normal; only tabGL Entry is read from the duckdb_conn.

Reuses get_opening_balance() for Account Closing Balance unchanged,
reuses all downstream compute helpers (calculate_values, prepare_data,
etc.) unchanged.

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

* refactor: maintain sync dependency in report master

(cherry picked from commit 5c536b8ad1)

# Conflicts:
#	erpnext/accounts/report/accounts_payable/accounts_payable.json
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.json
#	erpnext/accounts/report/general_ledger/general_ledger.json
#	erpnext/accounts/report/trial_balance/trial_balance.json

* refactor: DB agnostic method names

(cherry picked from commit f40cd41801)

* feat(general-ledger): implement execute_synced_report with full parity to normal report

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

# Conflicts:
#	erpnext/accounts/report/general_ledger/general_ledger.json

* feat(balance-sheet): implement execute_synced_report with full parity to normal report

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

# Conflicts:
#	erpnext/accounts/report/balance_sheet/balance_sheet.json

* feat(profit-and-loss): implement execute_synced_report with full parity to normal report

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

# Conflicts:
#	erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json

* refactor: synced reports should be enabled on sites based on requirements

(cherry picked from commit 963bbc8729)

# Conflicts:
#	erpnext/accounts/report/general_ledger/general_ledger.json

* chore: resolve conflicts

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 16:03:26 +05:30
ruthra kumar
e8cdad1c6b Merge pull request #57082 from frappe/mergify/bp/version-15-hotfix/pr-56628
fix(journal-entry): fetch outstanding on foreign currency (backport #56628)
2026-07-13 13:05:57 +05:30
mergify[bot]
808109abc0 feat(opening invoice creation tool): add project to opening invoice child row (backport #54662) (#56400)
Co-authored-by: Ravibharathi <131471282+ravibharathi656@users.noreply.github.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-13 07:08:33 +00:00
SowmyaArunachalam
ace51fccfa fix(journal-entry): fetch outstanding on foreign currency
(cherry picked from commit 07f641c48c)
2026-07-13 07:07:36 +00:00
Mihir Kandoi
8bd35d2255 Merge pull request #57074 from frappe/mergify/bp/version-15-hotfix/pr-57073
fix: make represents company field in purchase invoice ignore user pe… (backport #57073)
2026-07-13 12:13:49 +05:30
Mihir Kandoi
80548a1282 Merge pull request #56986 from frappe/mergify/bp/version-15-hotfix/pr-56915
feat(stock): support partial transfer from pick list (backport #56915)
2026-07-13 11:57:34 +05:30
Mihir Kandoi
94d1fd6c42 chore: resolve conflicts 2026-07-13 11:54:45 +05:30
Sudharsanan11
001f3a36ab chore(stock): merge version-15-hotfix into pick list partial-transfer backport 2026-07-13 09:11:06 +05:30
Mihir Kandoi
b7f276989d fix: make represents company field in purchase invoice ignore user permissions
(cherry picked from commit 6729a53fee)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
2026-07-12 18:39:09 +00:00
Shllokkk
2e72d22328 Merge pull request #55720 from frappe/mergify/bp/version-15-hotfix/pr-55627
fix(inactive_customers): add allowlist for doctype filter and migrate… (backport #55627)
2026-07-12 13:44:24 +05:30
mergify[bot]
cfc546cf3f fix(accounts): retain invoice table on opening invoice creation error (backport #56353) (#57057)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-07-12 07:46:00 +00:00
Shllokkk
ede2a68afd fix: set customer group in inactive customers report test setup 2026-07-12 12:56:41 +05:30
Shllokkk
29179240cf Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-55627 2026-07-12 10:53:10 +05:30
mergify[bot]
bd6269918f fix: remove incorrect Payable account_type from Customer Deposits in Philippines CoA (backport #57018) (#57060)
Co-authored-by: Raghav Ruia <168326921+raghavisruia@users.noreply.github.com>
2026-07-11 17:58:53 +00:00
mergify[bot]
1cb4307578 fix: update events order by date asc (backport #56963) (#57055)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
2026-07-11 18:45:22 +05:30
mergify[bot]
3e2a734414 fix: map stock_qty in apply_price_list_on_item (backport #56869) (#57051)
* fix: map stock_qty in apply_price_list_on_item (#56869)

(cherry picked from commit 199eeff22c)

# Conflicts:
#	erpnext/stock/get_item_details.py

* chore: resolve conflicts

---------

Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-07-11 10:13:42 +00:00
mergify[bot]
0f6b2b68fa fix: fetch payment entry reference amounts from invoice (backport #56928) (#57041)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-07-10 11:50:22 +00:00
mergify[bot]
8688f1f51a fix: display outstanding amount using company default currency (backport #56785) (#57008)
Co-authored-by: S Sakthivel Murugan <s.sakthivelmurugan2003@gmail.com>
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-10 10:25:47 +00:00
mergify[bot]
9d654b7890 fix(stock): correct stock ageing value for moving average and lifo items (backport #56693) (#57016)
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

(cherry picked from commit 9cb6610b9e)

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

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-10 15:44:39 +05:30
ruthra kumar
755e151764 Merge pull request #57022 from frappe/mergify/bp/version-15-hotfix/pr-56801
refactor: reversing exchange rate revaluation journals (backport #56801)
2026-07-10 13:19:01 +05:30
Mihir Kandoi
f14615e826 Merge pull request #57027 from aerele/backport-56913-version-15-hotfix
fix: make trend report based-on and group-by column labels translatable
2026-07-10 12:44:09 +05:30
Mihir Kandoi
12b826484b Merge pull request #57021 from aerele/backport-56923-version-15-hotfix
perf: avoid per-row Warehouse doc fetches in auto reorder job
2026-07-10 12:44:02 +05:30
Mihir Kandoi
9c0ea9ffbb Merge pull request #57019 from aerele/backport-56932-version-15-hotfix
fix: validate planned end date is not before planned start date in wo…
2026-07-10 12:43:01 +05:30
ruthra kumar
c89f9720bc chore: resolve conflicts 2026-07-10 12:33:53 +05:30
pandiyan
d2d5fae033 fix: make trend report based-on and group-by column labels translatable
based_wise_columns_query() and group_wise_column() built column labels as
raw strings, so headers like Item, Item Name, Customer, Supplier, and
Territory never went through the _() translation function and stayed in
English regardless of the user's language, while period and total columns
translated fine. Build these as column dicts with an explicit _()-wrapped
label instead, so they're translated the same way as the rest of the report.

(cherry picked from commit 015fa68fc0)
2026-07-10 12:01:56 +05:30
Pandiyan P
4091188908 fix(stock): pick list serial batch posting date (#56957) (#57014)
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

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.

(cherry picked from commit 8b3caeb578)
2026-07-10 11:51:03 +05:30
ruthra kumar
0f688a1841 refactor(test): for reverse journals as well
(cherry picked from commit 65775e59a1)

# Conflicts:
#	erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py
2026-07-10 06:20:33 +00:00
mergify[bot]
6b022a5a7e fix: for purchases do voucher based reposting (backport #56601) (#56607)
* fix: for purchases do voucher based reposting (#56601)

(cherry picked from commit 5523c15ab8)

* chore: fix type hints

* chore: fix test case

* chore: fix linters issue

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-10 06:20:32 +00:00
ruthra kumar
3c18b172a4 refactor: handle reverse ERR journals in AR / AP report
(cherry picked from commit 6838242063)
2026-07-10 06:20:32 +00:00
ruthra kumar
d239273ce5 refactor: reversal capability on exchange rate revaluation
(cherry picked from commit a0b14c0607)

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

(cherry picked from commit 6beb3d2509)
2026-07-10 11:41:43 +05:30
pandiyan
de6a157773 fix: validate planned end date is not before planned start date in work order
Work Order allowed saving with a Planned End Date earlier than the Planned
Start Date without any validation. Add a validate_dates check that throws
when the planned end date precedes the planned start date.

(cherry picked from commit 2ec780cb35)
2026-07-10 11:36:05 +05:30
Mihir Kandoi
8bfba5fcf3 Merge pull request #57003 from aerele/backport-57001
fix(manufacturing): update work order status on partial pick-list tra…
2026-07-09 21:57:38 +05:30
rohitwaghchaure
53af4d53ef fix: partial delivery note against pick list (#56985) 2026-07-09 16:17:10 +00:00
pandiyan
afa72884bb fix(manufacturing): update work order status on partial pick-list transfer (backport #56800)
Backport of frappe/erpnext#56800 (via #56836/#57001). When a Pick List
transfers only some of the required raw materials, the min-fraction
rule keeps material_transferred_for_manufacturing at 0, leaving the
Work Order stuck at "Not Started" even though material is already in
WIP. Treat any submitted pick-list-linked Material Transfer stock
entry as evidence the order has started.
2026-07-09 21:33:01 +05:30
Mihir Kandoi
cb65b55568 Merge pull request #56987 from frappe/mergify/bp/version-15-hotfix/pr-56909
fix(stock): rename variant item_code/item_name when attribute abbreviation changes (backport #56909)
2026-07-09 21:23:40 +05:30
Mihir Kandoi
62ce6c2129 Merge pull request #56993 from frappe/mergify/bp/version-15-hotfix/pr-56925
perf: batch bin lookups in delivery note stock update (backport #56925)
2026-07-09 21:22:59 +05:30
Mihir Kandoi
6484f0de54 Merge pull request #56999 from frappe/mergify/bp/version-15-hotfix/pr-56255
Fix Variant Of filter to show only template items (backport #56255)
2026-07-09 21:11:59 +05:30
Mihir Kandoi
1824c59624 Merge pull request #56997 from frappe/mergify/bp/version-15-hotfix/pr-56979
fix: update BOM operations when routing is changed (backport #56979)
2026-07-09 20:48:41 +05:30
Mihir Kandoi
cef69b05a9 chore: resolve conflicts 2026-07-09 20:44:57 +05:30
Mohsin Akhtar
902a3c5c55 fix: update modified timestamp in item.json
(cherry picked from commit 54da9fc27a)

# Conflicts:
#	erpnext/stock/doctype/item/item.json
2026-07-09 15:12:41 +00:00
Mohsin Akhtar
fde7cd5345 fix: show only template items in Variant Of filter
(cherry picked from commit 2433129850)
2026-07-09 15:12:39 +00:00
Mohd Haris
de0c75bcba fix: update BOM operations when routing is changed
The routing field handler only fetched operations from the routing when
the operations table was empty. When a new BOM version is created (via
"New Version"), operations are copied from the source BOM, so selecting a
different routing left the old operations in place - both in the form and
after saving.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 758a837de4)
2026-07-09 15:11:43 +00:00
Mihir Kandoi
7f4c68c1da Merge pull request #56924 from frappe/revert-56429-mergify/bp/version-15-hotfix/pr-55191
Revert "refactor(sales_person_wise_transaction_summary): Replace SQL with que… (backport #55191)"
2026-07-09 20:38:24 +05:30
pandiyan
989a6b507d perf: batch bin lookups in delivery note stock update
update_current_stock() in delivery_note.py used to call
frappe.db.get_value("Bin", ...) separately for every row in items and
every row in packed_items - so a delivery note with 200 items and 200
packed items made 400 separate database calls on every save.

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

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

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

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

(cherry picked from commit c0cfe5f363)
2026-07-09 15:03:28 +00:00
Sudharsanan11
cd612434a4 fix(stock): backfill transferred qty for existing pick lists
Pick Lists transferred before this feature have transferred_qty = 0 and
their Stock Entry rows carry no pick_list_item link, so the new
is_fully_transferred check would never fire and, with the old
duplicate-entry guard removed, they could be transferred again. Set
transferred_qty = picked_qty for non-Delivery submitted pick lists that
already have a linked Stock Entry so they stay completed and locked.

(cherry picked from commit 903d78cc43)
2026-07-09 15:01:37 +00:00
Sudharsanan11
4a036b6a0b test(stock): add test for partial transfer status from pick list
(cherry picked from commit 6ecbe6fd4b)

# Conflicts:
#	erpnext/stock/doctype/pick_list/test_pick_list.py
2026-07-09 15:01:36 +00:00
Sudharsanan11
02168b7b3d feat(stock): support partial transfer from pick list
Creating a Stock Entry from a Pick List blocked any further entry
(stock_entry_exists) and flipped the pick list to Completed as soon as
one entry existed, so picked stock could not be transferred in parts.

Track transferred_qty per Pick List Item (summed from submitted Stock
Entry rows via a new pick_list_item link, mirroring delivered_qty), add
a Partially Transferred status, and map each new Stock Entry from the
remaining qty so transfers can continue until fully transferred.

(cherry picked from commit af495ed253)

# Conflicts:
#	erpnext/stock/doctype/pick_list/pick_list.json
#	erpnext/stock/doctype/stock_entry/stock_entry.py
2026-07-09 15:01:35 +00:00
mergify[bot]
55e0d106c9 fix: added permission checks on various whitelisted functions (backport #56745) (#56945)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-09 16:00:19 +05:30
Diptanil Saha
2ed6f25d94 Merge pull request #56968 from frappe/mergify/bp/version-15-hotfix/pr-56674
fix(crm_settings): skip allowed users check when frappe crm is installed locally (backport #56674)
2026-07-09 15:51:58 +05:30
diptanilsaha
0924773856 chore: patch to clear out allowed users on crm_settings if frappe crm is installed on the site
(cherry picked from commit 0f987d7135)
2026-07-09 10:01:55 +00:00
diptanilsaha
983ec5660c fix(frappe_crm_api): handle failure for after_app_install and after_app_uninstall
(cherry picked from commit 2de423e225)
2026-07-09 10:01:54 +00:00
diptanilsaha
0a5daeed57 feat(crm_settings): auto-update crm sync settings on frappe crm install and uninstall
(cherry picked from commit c86aa2d6fe)
2026-07-09 10:01:54 +00:00
diptanilsaha
95b3bd4e1d fix(crm_settings): skip allowed users check when frappe crm is installed locally
(cherry picked from commit 41badb3d74)
2026-07-09 10:01:53 +00:00
Diptanil Saha
d0f29405d0 fix(patch): moved create_company_custom_fields from pre_model_sync to post_model_sync (#56962) 2026-07-09 08:13:08 +00:00
Shllokkk
8b4bc37a32 Merge pull request #56939 from frappe/mergify/bp/version-15-hotfix/pr-56926
fix: validate template and its variant in the same Pricing Rule (backport #56926)
2026-07-09 01:04:02 +05:30
mergify[bot]
51669501ab feat(sla): filter service level agreement link by document type (backport #56954) (#56955)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-08 17:29:42 +02:00
ruthra kumar
1239986756 Merge pull request #56937 from frappe/mergify/bp/version-15-hotfix/pr-54043
fix: precision issue causing reconciliation error (backport #54043)
2026-07-07 22:24:48 +05:30
Shllokkk
3df52c7fc6 fix: validate template and its variant in the same Pricing Rule
(cherry picked from commit a88048b378)
2026-07-07 14:29:54 +00:00
Dany Robert
d62831d885 fix: precision issue causing reconciliation error (#54043)
* fix: precision issue causing reconciliation error

* chore: code styling changes

* test: precision causing reconciliation failure

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

* fix(test): incorrect assertion on status

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
(cherry picked from commit be10c8ced9)
2026-07-07 14:28:57 +00:00
Vishnu Priya Baskaran
1fe9d569d6 Revert "refactor(sales_person_wise_transaction_summary): Replace SQL with que… (backport #55191)" 2026-07-07 12:21:12 +05:30
Shllokkk
6165d2fa94 Merge pull request #56096 from frappe/mergify/bp/version-15-hotfix/pr-56088
fix(journal entry): validate opening entry against pcv on save (backport #56088)
2026-07-06 15:24:17 +05:30
ruthra kumar
ec1ca6ba95 Merge pull request #56875 from frappe/mergify/bp/version-15-hotfix/pr-56852
fix: race condition in process pcv (backport #56852)
2026-07-04 16:44:37 +05:30
ruthra kumar
dcea09ea8f chore: linter fix
(cherry picked from commit a9ffdac806)
2026-07-04 16:26:19 +05:30
ruthra kumar
6945e1292b refactor: prevent whole table scan while scheduling next date
- helps in concurrency isolation

(cherry picked from commit 21f4603144)
2026-07-04 16:26:10 +05:30
ruthra kumar
1ee8904a94 fix: prevent repeatable read related concurrency errors
Process Period Closing Voucher and Process Period Closing Voucher
Details are trackers how the jobs are processed. Keep transactions on
them very short.

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

(cherry picked from commit ff6881764b)
2026-07-04 10:53:02 +00:00
Pandiyan P
75a814de36 Merge pull request #56862 from frappe/mergify/bp/version-15-hotfix/pr-56837
fix: remove company default on cost center in stock entry detail  (backport #56837)
2026-07-04 13:10:47 +05:30
pandiyan
5cedc50c55 fix: use change_settings decorator matching version-15-hotfix test utils
ERPNextTestSuite doesn't exist on this branch; the cherry-picked test
used the develop-branch decorator style, breaking ruff (F821) in CI.
2026-07-04 12:53:57 +05:30
Pandiyan P
da623eb124 fix: resolve conflicts 2026-07-04 12:45:09 +05:30
Shllokkk
ece1c9872f fix: use live source warehouse valuation for internal transfer purchse receipts (#56812)
fix: use live source warehouse valuation for internal transfer purchase receipts
2026-07-03 22:49:33 +05:30
Mihir Kandoi
47121cecb3 Merge pull request #56838 from frappe/mergify/bp/version-15-hotfix/pr-56826
fix: FIFO queue checks and incorrect entries filter in stock ledger reports (backport #56826)
2026-07-03 21:59:18 +05:30
Mihir Kandoi
93f33cc5cf test: fresh item per test, v15 only rolls back at class teardown 2026-07-03 21:42:58 +05:30
Mihir Kandoi
0ab94a929a chore: formatting 2026-07-03 21:20:32 +05:30
Mihir Kandoi
cecfcf82e0 fix: incorrect import path for FrappeTestCase 2026-07-03 21:14:43 +05:30
pandiyan
1a52c58666 test: cover cost center fallback to item group default in manufacture entry
the existing test_cost_center_for_manufacture only checks a raw material
row against an item-level override, which is set independently of the
":company" default guard and never exercised the bug.

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

(cherry picked from commit edfa0a7a1d)

# Conflicts:
#	erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
2026-07-03 15:42:58 +00:00
Khushi Rawat
3e6a5b3a0b Merge pull request #56797 from Jatin3128/fix/asset-expense-account-purchase-receipt
fix: fetch asset category expense account on purchase receipt
2026-07-03 17:31:26 +05:30
Mihir Kandoi
db4136b258 fix: use correct import 2026-07-03 13:54:07 +05:30
Mihir Kandoi
f3dd90bb0a test: drop redundant cleanup, db rolls back after each test
(cherry picked from commit 3b1e57966e)
2026-07-03 07:50:02 +00:00
Mihir Kandoi
3c47adb021 fix: address review comments
- restore mutated SLE after test via addCleanup
- explicit return False in has_difference
- comment the fifo_stock_diff guard for non-queue predecessors

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

(cherry picked from commit 94ab09e4a3)

# Conflicts:
#	erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py
2026-07-03 07:50:01 +00:00
mergify[bot]
1cf94f5501 fix: replay immutable SLE qty for serial/batch bundle valuation (backport #56814) (#56834)
fix: replay immutable SLE qty for serial/batch bundle valuation (#56814)

(cherry picked from commit ecc8ec672b)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-03 12:37:11 +05:30
mergify[bot]
b0446b6a09 fix: don't attempt to create SABB for non-serialized / non-batch items (backport #56627) (#56802)
* fix: don't attempt to create SABB for non-serialized / non-batch items (#56627)

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

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

(cherry picked from commit 5b738b7b0d)

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

* chore: resolve conflicts

---------

Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-03 10:35:17 +05:30
mergify[bot]
5666fd1334 fix(company): ignore user permissions for link fields having link to Account and Cost Center (backport #56748) (#56794)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-02 13:02:09 +00:00
jatin3128
b7e0331d56 fix: fetch asset category expense account on purchase receipt
Backport of #41024. Fixed-asset items now resolve expense_account from
the asset category (CWIP account when CWIP accounting is enabled,
otherwise the fixed asset account) for Purchase Receipt, Purchase Order
and Material Request, matching v16 behavior. v15 previously did this only
for Purchase Invoice.
2026-07-02 16:38:02 +05:30
ruthra kumar
16b90ab20f Merge pull request #56776 from frappe/mergify/bp/version-15-hotfix/pr-54783
fix: disallow editing on reversal journals (backport #54783)
2026-07-02 14:57:21 +05:30
mergify[bot]
44a7013ab4 fix: block serialized to non-serialized item change when SABB exists (backport #56773) (#56774)
* fix: block serialized to non-serialized item change when SABB exists (#56773)

(cherry picked from commit 0e8ae7548d)

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

* chore: fix conflicts

* chore: fix conflicts

Remove test for variant UOM mismatch and related logic.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-02 09:26:04 +00:00
ruthra kumar
ac2e69fbfd fix: disallow editing on reversal journals
(cherry picked from commit 26ca7445eb)
2026-07-02 09:07:45 +00:00
Nabin Hait
080b8d5183 Merge pull request #56768 from frappe/backport/clear-clearance-date-amend-v15
fix(accounts): clear clearance date when amending reconciled voucher (backport #55947)
2026-07-02 13:31:07 +05:30
Mihir Kandoi
7f2fb78d94 Merge pull request #56763 from frappe/mergify/bp/version-15-hotfix/pr-56757
fix: don't treat batch slot at FIFO queue head as qty slot (backport #56757)
2026-07-02 13:19:20 +05:30
Nabin Hait
5f14f2ccd6 fix(accounts): clear clearance date when amending reconciled voucher (backport #55947)
When a reconciled voucher (Payment Entry / Journal Entry / Purchase
Invoice / Sales Invoice) is cancelled, amended and resubmitted, the
stale clearance date was carried into the amended draft because the
framework ignores `no_copy` while amending.

Add a shared `before_insert` hook on AccountsController that clears
`clearance_date` (top-level field and Sales Invoice `payments` rows)
on amendment. Reconciliation still sets the value post-submit via
`frappe.db.set_value`, which bypasses this path.

Manual backport of #55947 to version-15-hotfix (Mergify's auto-backport
#55972 committed conflict markers; this replaces it).
2026-07-02 12:54:27 +05:30
Mihir Kandoi
4a6587b48e test: assert full negative batch slot in ageing regression test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 8928b42d5d)
2026-07-02 07:14:14 +00:00
Mihir Kandoi
a9330e8900 fix: don't treat batch slot at FIFO queue head as qty slot
An incoming SLE without resolvable serial/batch details hit the
negative-head branch in _compute_incoming_stock even when the head was
a batch slot, because flt() on the batch number string returns 0.0.
_add_to_negative_fifo_head then crashed with
"TypeError: can only concatenate str (not 'float') to str".

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c47a95a4d2)
2026-07-02 07:14:14 +00:00
mergify[bot]
71a2d6e43d fix: validate reverse GL entries on current date under immutable ledger (backport #56709) (#56751)
* fix: validate reverse GL entries on current date under immutable ledger (#56709)

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

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

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

Follow-up to #55268.

* test: reset frozen till date after reverse entry test

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

* fix: prefer explicit posting_date under immutable ledger

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

(cherry picked from commit cab1b129c0)

# Conflicts:
#	erpnext/accounts/general_ledger.py

* fix: resolved conflicts

Removed outdated check for freezing date in general ledger validation.

* fix: Update check_freezing_date function call parameters

* fix: accounts_frozen_till_date to acc_frozen_upto

* test: acc_frozen_upto setting in test_period_closing_voucher

---------

Co-authored-by: Nihantra C. Patel <141945075+Nihantra-Patel@users.noreply.github.com>
2026-07-02 12:27:20 +05:30
ruthra kumar
a0b527d019 refactor: update title for process statement of accounts (backport #56754) (#56755)
refactor: update title for process statement of accounts

(cherry picked from commit 04468c3c33)

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
2026-07-02 12:17:01 +05:30
ruthra kumar
f139466851 refactor: update title for process statement of accounts
(cherry picked from commit 04468c3c33)
2026-07-02 06:42:36 +00:00
Mihir Kandoi
9d3f9cce36 Merge pull request #56692 from aerele/backport-55585
fix(stock): set stock received but not billed account for purchase (#…
2026-07-01 13:55:00 +05:30
Pandiyan P
38b7042481 fix(stock): set stock received but not billed account for purchase (#55149)
(cherry picked from commit c4d28a2612)
2026-07-01 13:21:31 +05:30
Mihir Kandoi
5ee3a183b3 Merge pull request #56682 from frappe/mergify/bp/version-15-hotfix/pr-56671
fix(selling): update sales order per billed on credit note submission (backport #56671)
2026-07-01 12:44:58 +05:30
Sudharsanan11
a6d790f321 test(selling): add test to validate the per billed after credit note submission
(cherry picked from commit 710e021638)
2026-07-01 09:59:36 +05:30
Sudharsanan11
63e85c6551 fix(selling): update sales order per billed on credit note submission
(cherry picked from commit 224cf19f33)
2026-07-01 03:59:59 +00:00
Shllokkk
43ff69429f Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-55627 2026-06-26 14:41:07 +05:30
Shllokkk
2ed7c84d0a fix(journal entry): validate opening entry against pcv on save
(cherry picked from commit f8aa4c730c)

# Conflicts:
#	erpnext/accounts/doctype/journal_entry/journal_entry.py
#	erpnext/accounts/general_ledger.py
2026-06-19 01:06:49 +05:30
ruthra kumar
bd41e741be test(inactive customers): use FrappeTestCase 2026-06-10 10:16:32 +05:30
Nabin Hait
30efbf8c9b test(inactive_customers): remove non-positive days test case
(cherry picked from commit 601f39dda7)
2026-06-08 07:50:34 +00:00
Nabin Hait
1cba32eb1a refactor(inactive_customers): rename sales alias to sales_doctype
(cherry picked from commit 8d7edafc99)
2026-06-08 07:50:34 +00:00
Nabin Hait
ebb7b37558 refactor(inactive_customers): use descriptive aliases and add tests
Rename single-letter query-builder aliases (C, DT) to readable names
(customer, sales) and add report tests covering the column contract,
validation guards, and the days-since-last-order threshold.

(cherry picked from commit 8f15dd4d5d)
2026-06-08 07:50:33 +00:00
Shllokkk
29dd6e6681 fix(inactive_customers): add allowlist for doctype filter and migrate to qb
(cherry picked from commit 2ecf8b0466)
2026-06-08 07:50:33 +00:00
110 changed files with 4124 additions and 550 deletions

View File

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

View File

@@ -115,7 +115,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"):
@@ -126,6 +126,14 @@ def get_default_company_bank_account(company, party_type, party):
"Bank Account", {"company": company, "is_company_account": 1, "is_default": 1}
)
if not ignore_permissions:
default_company_bank_account = (
default_company_bank_account
if default_company_bank_account
and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select")
else None
)
return default_company_bank_account

View File

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

View File

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

View File

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

View File

@@ -130,7 +130,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
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()
@@ -213,7 +214,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
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()
@@ -287,3 +289,83 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
@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)
expected = [
(usd_account, new_balance, 0.0, 100.0, 0.0),
(usd_account, 0.0, old_balance, 0.0, 100.0),
(gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss),
]
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)

View File

@@ -40,6 +40,10 @@ frappe.ui.form.on("Journal Entry", {
},
refresh: function (frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
frm.set_read_only();
}
erpnext.toggle_naming_series();
if (frm.doc.docstatus > 0) {

View File

@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details,
)
from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
@@ -123,6 +124,9 @@ class JournalEntry(AccountsController):
if not self.is_opening:
self.is_opening = "No"
if self.is_opening == "Yes":
validate_opening_entry_against_pcv(self.company)
self.clearance_date = None
self.validate_party()

View File

@@ -1,12 +1,15 @@
frappe.listview_settings["Journal Entry"] = {
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark"],
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark", "reversal_of"],
get_indicator: function (doc) {
if (doc.docstatus == 0) {
return [__("Draft", "red", "docstatus,=,0")];
} else if (doc.docstatus == 2) {
return [__("Cancelled", "grey", "docstatus,=,2")];
} else {
return [__(doc.voucher_type), "blue", "voucher_type,=," + doc.voucher_type];
} else if (doc.docstatus === 1) {
if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") {
return [__("Reversal Of Exchange Rate Revaluation"), "blue"];
}
return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`];
}
},
};

View File

@@ -24,15 +24,22 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
setTimeout(
() => {
frm.doc.import_in_progress = false;
frm.clear_table("invoices");
frm.refresh_fields();
frm.page.clear_indicator();
frm.dashboard.hide_progress();
if (frm.doc.invoice_type == "Sales") {
frappe.msgprint(__("Opening Sales Invoices have been created."));
if (!data.errors) {
frm.clear_table("invoices");
frm.refresh_fields();
const message =
frm.doc.invoice_type == "Sales"
? __("Opening Sales Invoice(s) have been created.")
: __("Opening Purchase Invoice(s) have been created.");
frappe.show_alert({
message: message,
indicator: "green",
});
} else {
frappe.msgprint(__("Opening Purchase Invoices have been created."));
frm.refresh_fields();
}
},
1500,
@@ -75,29 +82,31 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
},
setup_company_filters: function (frm) {
frm.set_query("cost_center", "invoices", function (doc, cdt, cdn) {
return {
filters: {
company: doc.company,
},
};
frm.events.apply_company_query_filter(frm, "cost_center", "invoices", { is_group: 0 });
frm.events.apply_company_query_filter(frm, "project", "invoices");
frm.events.apply_company_query_filter(frm, "project");
frm.events.apply_company_query_filter(frm, "cost_center", undefined, { is_group: 0 });
frm.events.apply_company_query_filter(frm, "temporary_opening_account", "invoices", {
account_type: "Temporary",
is_group: 0,
});
},
frm.set_query("cost_center", function (doc) {
apply_company_query_filter: function (frm, field_name, child_doctype = null, filters = {}) {
const query = function (doc) {
return {
filters: {
company: doc.company,
...filters,
},
};
});
};
frm.set_query("temporary_opening_account", "invoices", function (doc, cdt, cdn) {
return {
filters: {
company: doc.company,
},
};
});
if (child_doctype) {
frm.set_query(field_name, child_doctype, query);
} else {
frm.set_query(field_name, query);
}
},
company: function (frm) {
@@ -121,10 +130,7 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
},
invoice_type: function (frm) {
$.each(frm.doc.invoices, (idx, row) => {
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
row.party = "";
});
frm.clear_table("invoices");
frm.refresh_fields();
},
@@ -165,7 +171,19 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
});
frappe.ui.form.on("Opening Invoice Creation Tool Item", {
invoices_add: (frm) => {
invoices_add: (frm, cdt, cdn) => {
const row = frappe.get_doc(cdt, cdn);
const field_copy = [];
["project", "cost_center"].forEach((fieldname) => {
if (frm.doc[fieldname]) {
frappe.model.set_value(cdt, cdn, fieldname, frm.doc[fieldname]);
} else {
field_copy.push(fieldname);
}
});
frm.script_manager.copy_from_first_row("invoices", row, field_copy);
frm.trigger("update_invoice_table");
},
});

View File

@@ -122,6 +122,17 @@ class OpeningInvoiceCreationTool(Document):
if not row.get(scrub(d)):
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
self.validate_temporary_opening_account(row)
def validate_temporary_opening_account(self, row):
account_type = frappe.get_cached_value("Account", row.temporary_opening_account, "account_type")
if account_type != "Temporary":
frappe.throw(
_("Row #{0}: {1} account is not of type {2}").format(
row.idx, row.temporary_opening_account, "Temporary"
)
)
def get_invoices(self):
invoices = []
for row in self.invoices:
@@ -191,6 +202,7 @@ class OpeningInvoiceCreationTool(Document):
"description": row.item_name or "Opening Invoice Item",
income_expense_account_field: row.temporary_opening_account,
"cost_center": cost_center,
"project": row.get("project") or self.get("project"),
}
)
@@ -254,22 +266,32 @@ class OpeningInvoiceCreationTool(Document):
def start_import(invoices):
errors = 0
names = []
total = len(invoices)
for idx, d in enumerate(invoices):
# Scope each invoice to a savepoint so a failure only undoes that invoice.
# A plain rollback() would discard the whole transaction — including invoices
# imported earlier in this batch and the error logs of earlier failures (the
# latter only survive on mariadb because the Error Log table is MyISAM; on
# postgres they would be lost). Rolling back to a savepoint keeps both.
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
frappe.db.savepoint(savepoint)
is_last = idx == total - 1
try:
invoice_number = None
if d.invoice_number:
invoice_number = d.invoice_number
publish(idx, len(invoices), d.doctype)
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
doc.insert(set_name=invoice_number)
doc.submit()
frappe.db.commit()
names.append(doc.name)
publish(idx, total, d.doctype, errors=errors if is_last else None)
except Exception:
errors += 1
frappe.db.rollback()
doc.log_error("Opening invoice creation failed")
publish(idx, total, d.doctype, errors=errors if is_last else None)
if errors:
frappe.msgprint(
_("You had {} errors while creating opening invoices. Check {} for more details").format(
@@ -281,7 +303,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(
@@ -289,6 +311,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

@@ -3,6 +3,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, today
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
@@ -11,6 +12,7 @@ from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension imp
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
from erpnext.projects.doctype.project.test_project import make_project
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
@@ -27,21 +29,26 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
self,
invoice_type="Sales",
company=None,
party_1=None,
party_2=None,
invoice_number=None,
invoices=None,
project=None,
cost_center=None,
department=None,
return_doc=False,
):
doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(
invoice_type=invoice_type,
company=company,
party_1=party_1,
party_2=party_2,
invoice_number=invoice_number,
invoices=invoices,
project=project,
cost_center=cost_center,
department=department,
)
doc.update(args)
if return_doc:
return doc
return doc.make_invoices()
def test_opening_sales_invoice_creation(self):
@@ -50,8 +57,8 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
self.assertEqual(len(invoices), 2)
expected_value = {
"keys": ["customer", "outstanding_amount", "status"],
0: ["_Test Customer", 300, "Overdue"],
1: ["_Test Customer 1", 250, "Overdue"],
0: ["_Test Customer", 200, "Overdue"],
1: ["_Test Customer 1", 200, "Overdue"],
}
self.check_expected_values(invoices, expected_value)
@@ -68,48 +75,34 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
for field_idx, field in enumerate(expected_value["keys"]):
self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx])
def test_opening_invoice_requires_temporary_account_type(self):
doc = self.make_invoices(company="_Test Opening Invoice Company", return_doc=True)
doc.invoices[0].temporary_opening_account = "Sales - _TOIC"
self.assertRaises(frappe.ValidationError, doc.make_invoices)
def test_opening_purchase_invoice_creation(self):
invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company")
self.assertEqual(len(invoices), 2)
expected_value = {
"keys": ["supplier", "outstanding_amount", "status"],
0: ["_Test Supplier", 300, "Overdue"],
1: ["_Test Supplier 1", 250, "Overdue"],
0: ["_Test Supplier", 200, "Overdue"],
1: ["_Test Supplier 1", 200, "Overdue"],
}
self.check_expected_values(invoices, expected_value, "Purchase")
def test_opening_sales_invoice_creation_with_missing_debit_account(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account")
frappe.db.set_value("Company", company, "default_receivable_account", "")
old_default_receivable_account = frappe.db.get_value(
"Company", "_Test Opening Invoice Company", "default_receivable_account"
)
frappe.db.set_value("Company", "_Test Opening Invoice Company", "default_receivable_account", "")
if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"):
cc = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Opening Invoice Company",
"is_group": 1,
"company": "_Test Opening Invoice Company",
}
)
cc.insert(ignore_mandatory=True)
cc2 = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "Main",
"is_group": 0,
"company": "_Test Opening Invoice Company",
"parent_cost_center": cc.name,
}
)
cc2.insert()
frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC")
self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2)
self.make_invoices(
company="_Test Opening Invoice Company",
invoices=[{"party": party_1}, {"party": party_2}],
)
# Check if missing debit account error raised
error_log = frappe.db.exists(
@@ -119,74 +112,113 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
self.assertTrue(error_log)
# teardown
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
def test_renaming_of_invoice_using_invoice_number_field(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
self.make_invoices(
company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11"
frappe.db.set_value(
"Company",
"_Test Opening Invoice Company",
"default_receivable_account",
old_default_receivable_account,
)
sales_inv1 = frappe.get_all("Sales Invoice", filters={"customer": "Customer A"})[0].get("name")
sales_inv2 = frappe.get_all("Sales Invoice", filters={"customer": "Customer B"})[0].get("name")
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
def test_renaming_of_invoice_using_invoice_number_field(self):
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
inv_num = f"TEST-NEW-INV-{frappe.generate_hash(length=8)}"
invoices = self.make_invoices(
company="_Test Opening Invoice Company",
invoices=[
{"party": party_1, "invoice_number": inv_num},
{"party": party_2},
],
)
# teardown
for inv in [sales_inv1, sales_inv2]:
doc = frappe.get_doc("Sales Invoice", inv)
doc.cancel()
self.assertEqual(invoices[0], inv_num)
def test_opening_invoice_with_accounting_dimension(self):
invoices = self.make_invoices(
invoice_type="Sales", company="_Test Opening Invoice Company", department="Sales - _TOIC"
)
expected_value = {
"keys": ["customer", "outstanding_amount", "status", "department"],
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
}
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
def tearDown(self):
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
)
project_1 = make_project(
{"project_name": "Test Opening Invoice projecty 01", "company": "_Test Opening Invoice Company"}
)
project_2 = make_project(
{"project_name": "Test Opening Invoice projecty 02", "company": "_Test Opening Invoice Company"}
)
doc.invoices[0].project = project_1.name
doc.invoices[1].project = project_2.name
invoices = doc.make_invoices()
sales_invoice_1 = frappe.get_doc("Sales Invoice", invoices[0])
sales_invoice_2 = frappe.get_doc("Sales Invoice", invoices[1])
self.assertEqual(sales_invoice_1.items[0].project, project_1.name)
self.assertEqual(sales_invoice_2.items[0].project, project_2.name)
@classmethod
def tearDownClass(cls):
disable_dimension()
super().tearDownClass()
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company")
default_invoices = []
default_invoice_rows = [
{
"qty": 1.0,
"outstanding_amount": 200,
"party": f"_Test {party}",
"item_name": "Opening Item",
"due_date": add_days(today(), -10),
"posting_date": add_days(today(), -15),
"temporary_opening_account": get_temporary_opening_account(company),
},
{
"qty": 1.0,
"outstanding_amount": 200,
"party": f"_Test {party} 1",
"item_name": "Opening Item",
"due_date": add_days(today(), -10),
"posting_date": add_days(today(), -15),
"temporary_opening_account": get_temporary_opening_account(company),
},
]
for row in args.get("invoices") or default_invoice_rows:
default_invoices.append(
{
"qty": row.get("qty") or 1.0,
"outstanding_amount": row.get("outstanding_amount") or 200,
"party": row.get("party") or f"_Test {party}",
"item_name": row.get("item_name") or "Opening Item",
"due_date": row.get("due_date") or add_days(today(), -10),
"posting_date": row.get("posting_date") or add_days(today(), -15),
"temporary_opening_account": row.get("temporary_opening_account")
or get_temporary_opening_account(company),
"invoice_number": row.get("invoice_number"),
"project": row.get("project"),
"cost_center": row.get("cost_center"),
}
)
invoice_dict = frappe._dict(
{
"company": company,
"invoice_type": args.get("invoice_type", "Sales"),
"invoices": [
{
"qty": 1.0,
"outstanding_amount": 300,
"party": args.get("party_1") or f"_Test {party}",
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": args.get("invoice_number"),
},
{
"qty": 2.0,
"outstanding_amount": 250,
"party": args.get("party_2") or f"_Test {party} 1",
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": None,
},
],
"project": args.get("project"),
"cost_center": args.get("cost_center"),
"invoices": default_invoices,
}
)
invoice_dict.update(args)
invoice_dict.invoices = default_invoices
return invoice_dict

View File

@@ -19,7 +19,8 @@
"qty",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break"
"dimension_col_break",
"project"
],
"fields": [
{
@@ -79,6 +80,7 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Outstanding Amount",
"options": "Company:company:default_currency",
"reqd": 1
},
{
@@ -111,11 +113,17 @@
"fieldname": "invoice_number",
"fieldtype": "Data",
"label": "Invoice Number"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"istable": 1,
"links": [],
"modified": "2022-03-21 19:31:45.382656",
"modified": "2026-07-03 15:17:11.938499",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",
@@ -126,4 +134,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -25,6 +25,7 @@ class OpeningInvoiceCreationToolItem(Document):
party: DF.DynamicLink
party_type: DF.Link | None
posting_date: DF.Date | None
project: DF.Link | None
qty: DF.Data | None
temporary_opening_account: DF.Link | None
# end: auto-generated types

View File

@@ -2691,6 +2691,9 @@ def get_party_details(company, party_type, party, date, cost_center=None):
if not frappe.db.exists(party_type, party):
frappe.throw(_("{0} {1} does not exist").format(_(party_type), party))
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
frappe.has_permission(party_type, ptype, party, throw=True)
party_account = get_party_account(party_type, party, company)
account_currency = get_account_currency(party_account)
account_balance = (
@@ -2707,7 +2710,7 @@ def get_party_details(company, party_type, party, date, cost_center=None):
)
if party_type in ["Customer", "Supplier"]:
party_bank_account = get_party_bank_account(party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False)
return {
"party_account": party_account,
@@ -2808,9 +2811,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

@@ -796,10 +796,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

@@ -155,6 +155,7 @@ class TestPaymentReconciliation(FrappeTestCase):
sinv = create_sales_invoice(
qty=qty,
rate=rate,
posting_date=posting_date,
company=self.company,
customer=self.customer,
item_code=self.item,
@@ -2146,7 +2147,7 @@ class TestPaymentReconciliation(FrappeTestCase):
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)
@@ -2540,6 +2541,76 @@ class TestPaymentReconciliation(FrappeTestCase):
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 make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):

View File

@@ -367,6 +367,7 @@ class PaymentRequest(Document):
bank_amount=bank_amount,
created_from_payment_request=True,
)
payment_entry.set_missing_ref_details(force=True)
payment_entry.update(
{

View File

@@ -618,6 +618,22 @@ class TestPaymentRequest(FrappeTestCase):
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

@@ -379,12 +379,15 @@ class TestPeriodClosingVoucher(unittest.TestCase):
self.make_period_closing_voucher(posting_date="2021-03-31")
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", "2021-12-31")
try:
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
)
finally:
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None)
totals_after_cancel = frappe.db.sql(
"""

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

@@ -336,6 +336,31 @@ class TestPricingRule(FrappeTestCase):
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

@@ -86,50 +86,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()
@@ -247,11 +252,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)
@@ -261,15 +266,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",
@@ -277,6 +282,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,
@@ -441,6 +447,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")
@@ -526,10 +537,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":
@@ -576,17 +587,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

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

View File

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

View File

@@ -1397,8 +1397,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",
@@ -1660,7 +1662,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-03-17 20:44:00.221219",
"modified": "2026-07-12 23:54:21.263951",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -697,13 +697,15 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, adv_adj)
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -770,7 +772,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date
@@ -802,13 +804,24 @@ def check_freezing_date(posting_date, adv_adj=False):
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
def validate_opening_entry_against_pcv(company):
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
frappe.throw(
_("Opening Entry can not be created after Period Closing Voucher is created."),
_(
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
).format(
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
+ _("Read the docs")
+ "</a>"
),
title=_("Invalid Opening Entry"),
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening:
validate_opening_entry_against_pcv(company)
# Local import so you don't have to touch file-level imports
from frappe.query_builder.functions import Max

View File

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

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2017-02-24 20:09:46.150861",
"modified": "2026-07-01 13:37:41.185347",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -15,6 +15,11 @@
"ref_doctype": "Purchase Invoice",
"report_name": "Accounts Payable",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"roles": [
{
"role": "Accounts User"
@@ -28,5 +33,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2017-03-06 05:52:06.235584",
"modified": "2026-07-01 13:37:44.167999",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -15,6 +15,11 @@
"ref_doctype": "Sales Invoice",
"report_name": "Accounts Receivable",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"roles": [
{
"role": "Accounts Manager"
@@ -22,5 +27,7 @@
{
"role": "Accounts User"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

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

View File

@@ -6,7 +6,7 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2018-09-07 12:18:21.850851",
"modified": "2026-06-22 13:38:25.236839",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Balance Sheet",
@@ -15,6 +15,11 @@
"ref_doctype": "GL Entry",
"report_name": "Balance Sheet",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"roles": [
{
"role": "Accounts User"
@@ -25,5 +30,7 @@
{
"role": "Auditor"
}
]
}
],
"synced_report": 0,
"timeout": 0
}

View File

@@ -4,14 +4,23 @@
import frappe
from frappe import _
from frappe.utils import cint, flt
from frappe.utils import add_days, cint, flt
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
calculate_values,
compute_growth_view_data,
filter_accounts,
filter_out_zero_value_rows,
get_accounting_entries,
get_accounts,
get_appropriate_currency,
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
prepare_data,
)
@@ -259,3 +268,196 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
chart["currency"] = currency
return chart
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):
frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
filters.period_start_date = period_list[0]["year_start_date"]
currency = filters.presentation_currency or frappe.get_cached_value(
"Company", filters.company, "default_currency"
)
asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list)
liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list)
equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list)
provisional_profit_loss, total_credit = get_provisional_profit_loss(
asset, liability, equity, period_list, filters.company, currency
)
message, opening_balance = check_opening_balance(asset, liability, equity)
data = []
data.extend(asset or [])
data.extend(liability or [])
data.extend(equity or [])
if opening_balance and round(opening_balance, 2) != 0:
unclosed = {
"account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
"account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
"warn_if_negative": True,
"currency": currency,
}
for period in period_list:
unclosed[period.key] = opening_balance
if provisional_profit_loss:
provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance
unclosed["total"] = opening_balance
data.append(unclosed)
if provisional_profit_loss:
data.append(provisional_profit_loss)
if total_credit:
data.append(total_credit)
columns = get_columns(
filters.periodicity, period_list, filters.accumulated_values, company=filters.company
)
chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
report_summary, primitive_summary = get_report_summary(
period_list, asset, liability, equity, provisional_profit_loss, currency, filters
)
if filters.get("selected_view") == "Growth":
compute_growth_view_data(data, period_list)
return columns, data, message, chart, report_summary, primitive_summary
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
accounts = get_accounts(filters.company, root_type)
if not accounts:
return None
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
company_currency = get_appropriate_currency(filters.company, filters)
gl_entries_by_account = {}
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type)
calculate_values(
accounts_by_name,
gl_entries_by_account,
period_list,
filters.accumulated_values,
False,
)
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
out = prepare_data(
accounts,
balance_must_be,
period_list,
company_currency,
accumulated_values=filters.accumulated_values,
)
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
if out:
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
return out
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type):
from erpnext.accounts.report.trial_balance.trial_balance import (
_extra_gl_conditions,
_fetch_gl_rows_duckdb,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
company = filters.company
year_start_date = period_list[0]["year_start_date"]
last_to_date = period_list[-1]["to_date"]
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
if not leaf_accounts:
return
opening_from_date = None
ignore_opening_entries = False
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
if not ignore_closing_balances:
last_pcv_list = frappe.db.get_all(
"Period Closing Voucher",
filters={
"docstatus": 1,
"company": company,
"period_end_date": ("<", filters.get("period_start_date") or year_start_date),
},
fields=["period_end_date", "name"],
order_by="period_end_date desc",
limit=1,
)
if last_pcv_list:
last_pcv = last_pcv_list[0]
pcv_entries = get_accounting_entries(
"Account Closing Balance",
None,
last_to_date,
filters,
root_type=root_type,
ignore_closing_entries=False,
period_closing_voucher=last_pcv.name,
)
if filters.get("presentation_currency"):
convert_to_presentation_currency(pcv_entries, get_currency(filters))
for entry in pcv_entries:
gl_entries_by_account.setdefault(entry.account, []).append(entry)
opening_from_date = add_days(last_pcv.period_end_date, 1)
ignore_opening_entries = True
extra_cond, extra_params = _extra_gl_conditions(filters)
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
base_conds = [
"company = ?",
"is_cancelled = 0",
f"account IN ({account_placeholders})",
]
base_params = [company, *leaf_accounts]
if ignore_opening_entries and not ignore_is_opening:
base_conds.append("is_opening = 'No'")
base_conds.extend(extra_cond)
base_params.extend(extra_params)
# Opening GL entries from DuckDB (entries before year_start_date)
open_conds = [*base_conds, "posting_date < ?"]
open_params = [*base_params, year_start_date]
if opening_from_date:
open_conds = [*open_conds, "posting_date >= ?"]
open_params = [*open_params, opening_from_date]
opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(opening_entries, get_currency(filters))
synthetic_open_date = add_days(year_start_date, -1)
for entry in opening_entries:
entry.posting_date = synthetic_open_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)
# Period GL entries from DuckDB (one aggregated query per period)
for period in period_list:
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
period_params = [*base_params, period.from_date, period.to_date]
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(period_entries, get_currency(filters))
for entry in period_entries:
entry.posting_date = period.to_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)

View File

@@ -6,11 +6,15 @@
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"idx": 3,
"is_standard": "Yes",
"letterhead": null,
"modified": "2025-11-05 15:47:59.597853",
"modified": "2026-07-01 13:36:06.682661",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -30,5 +34,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -792,3 +792,288 @@ def get_columns(filters):
columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}])
return columns
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):
return _execute_with_duckdb_conn(filters, conn)
frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
def _execute_with_duckdb_conn(filters, conn):
if not filters:
return [], []
account_details = {}
if filters.get("print_in_account_currency") and not filters.get("account"):
frappe.throw(_("Select an account to print in account currency"))
for acc in frappe.get_all("Account", fields=["name", "is_group"]):
account_details.setdefault(acc.name, acc)
if filters.get("party"):
filters.party = frappe.parse_json(filters.get("party"))
validate_filters(filters, account_details)
validate_party(filters)
filters = set_account_currency(filters)
columns = get_columns(filters)
res = get_result_duckdb(filters, account_details, conn)
return columns, res
def get_result_duckdb(filters, account_details, conn):
accounting_dimensions = []
if filters.get("include_dimensions"):
accounting_dimensions = get_accounting_dimensions()
gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn)
data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries)
return get_result_as_list(data, filters)
def get_gl_entries_duckdb(filters, accounting_dimensions, conn):
currency_map = get_currency(filters)
col_names = [
"gl_entry",
"posting_date",
"account",
"party_type",
"party",
"voucher_type",
"voucher_subtype",
"voucher_no",
"cost_center",
"project",
"against_voucher_type",
"against_voucher",
"account_currency",
"against",
"is_opening",
"creation",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
]
select_exprs = [
"name",
"posting_date",
"account",
"party_type",
"party",
"voucher_type",
"voucher_subtype",
"voucher_no",
"cost_center",
"project",
"against_voucher_type",
"against_voucher",
"account_currency",
"against",
"is_opening",
"creation",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
]
if filters.get("show_remarks"):
remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length")
if remarks_length:
select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})")
else:
select_exprs.append("remarks")
col_names.append("remarks")
if filters.get("add_values_in_transaction_currency"):
select_exprs += [
"debit_in_transaction_currency",
"credit_in_transaction_currency",
"transaction_currency",
]
col_names += [
"debit_in_transaction_currency",
"credit_in_transaction_currency",
"transaction_currency",
]
if accounting_dimensions:
select_exprs += accounting_dimensions
col_names += accounting_dimensions
order_by = "posting_date, account, creation"
if filters.get("include_dimensions"):
order_by = "posting_date, creation"
if filters.get("categorize_by") == "Categorize by Voucher":
order_by = "posting_date, voucher_type, voucher_no"
if filters.get("categorize_by") == "Categorize by Account":
order_by = "account, posting_date, creation"
if filters.get("include_default_book_entries"):
filters["company_fb"] = frappe.get_cached_value(
"Company", filters.get("company"), "default_finance_book"
)
conditions, params = _build_gl_conditions_duckdb(filters)
select_clause = ", ".join(select_exprs)
sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}'
rows = conn.execute(sql, params).fetchall()
gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows]
party_name_map = get_party_name_map()
for gl_entry in gl_entries:
if gl_entry.party_type and gl_entry.party:
gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party)
if filters.get("presentation_currency"):
return convert_to_presentation_currency(gl_entries, currency_map, filters)
return gl_entries
def _build_gl_conditions_duckdb(filters):
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
conditions = ["company = ?"]
params = [filters.company]
if filters.get("account"):
filters.account = get_accounts_with_children(filters.account)
if filters.account:
conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})")
params.extend(filters.account)
if filters.get("cost_center"):
filters.cost_center = get_cost_centers_with_children(filters.cost_center)
conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})")
params.extend(filters.cost_center)
if filters.get("voucher_no"):
conditions.append("voucher_no = ?")
params.append(filters.voucher_no)
if filters.get("against_voucher_no"):
conditions.append("against_voucher = ?")
params.append(filters.against_voucher_no)
if filters.get("ignore_err"):
err_journals = frappe.db.get_all(
"Journal Entry",
filters={
"company": filters.get("company"),
"docstatus": 1,
"voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]),
},
pluck="name",
)
if err_journals:
filters.update({"voucher_no_not_in": err_journals})
if filters.get("ignore_cr_dr_notes"):
system_generated = frappe.db.get_all(
"Journal Entry",
filters={
"company": filters.get("company"),
"docstatus": 1,
"voucher_type": ("in", ["Credit Note", "Debit Note"]),
"is_system_generated": 1,
},
pluck="name",
)
if system_generated:
vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated
filters.update({"voucher_no_not_in": vouchers_to_ignore})
if filters.get("voucher_no_not_in"):
vouchers = filters.voucher_no_not_in
conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})")
params.extend(vouchers)
if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"):
conditions.append("party_type IN ('Customer', 'Supplier')")
if filters.get("party_type"):
conditions.append("party_type = ?")
params.append(filters.party_type)
if filters.get("party"):
conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})")
params.extend(filters.party)
# from_date: skip when filtering by account/party to allow opening balance calc in Python
if filters.get("disable_opening_balance_calculation"):
if not ignore_is_opening:
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date >= ?")
params.append(filters.from_date)
elif not (
filters.get("account")
or filters.get("party")
or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
):
if not ignore_is_opening:
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date >= ?")
params.append(filters.from_date)
if not ignore_is_opening:
conditions.append("(posting_date <= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date <= ?")
params.append(filters.to_date)
if filters.get("project"):
conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})")
params.extend(filters.project)
company_fb = filters.get("company_fb") or frappe.get_cached_value(
"Company", filters.company, "default_finance_book"
)
if filters.get("include_default_book_entries"):
if filters.get("finance_book"):
if company_fb and cstr(filters.finance_book) != cstr(company_fb):
frappe.throw(
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
)
fb_vals = [cstr(filters.finance_book), ""]
else:
fb_vals = [cstr(company_fb), ""]
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)")
params.extend(fb_vals)
else:
if filters.get("finance_book"):
conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)")
params.append(cstr(filters.finance_book))
else:
conditions.append("(finance_book IN ('') OR finance_book IS NULL)")
if not filters.get("show_cancelled_entries"):
conditions.append("is_cancelled = 0")
accounting_dimensions_list = get_accounting_dimensions(as_list=False)
if accounting_dimensions_list:
for dimension in accounting_dimensions_list:
if not dimension.disabled and dimension.document_type != "Finance Book":
if filters.get(dimension.fieldname):
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
filters[dimension.fieldname] = get_dimension_with_children(
dimension.document_type, filters.get(dimension.fieldname)
)
vals = (
filters[dimension.fieldname]
if isinstance(filters[dimension.fieldname], list)
else [filters[dimension.fieldname]]
)
conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})")
params.extend(vals)
return conditions, params

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2017-02-24 20:12:40.282376",
"modified": "2026-07-01 13:36:14.934965",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -15,6 +15,12 @@
"ref_doctype": "GL Entry",
"report_name": "Profit and Loss Statement",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"roles": [
{
"role": "Accounts User"
@@ -25,5 +31,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -7,12 +7,20 @@ from frappe import _
from frappe.utils import flt
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
calculate_values,
compute_growth_view_data,
compute_margin_view_data,
filter_accounts,
filter_out_zero_value_rows,
get_accounts,
get_appropriate_currency,
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
prepare_data,
)
@@ -193,3 +201,125 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
chart["currency"] = currency
return chart
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):
frappe.throw(
_("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))
)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list)
expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list)
net_profit_loss = get_net_profit_loss(
income, expense, period_list, filters.company, filters.presentation_currency
)
data = []
data.extend(income or [])
data.extend(expense or [])
if net_profit_loss:
data.append(net_profit_loss)
columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company)
currency = filters.presentation_currency or frappe.get_cached_value(
"Company", filters.company, "default_currency"
)
chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency)
report_summary, primitive_summary = get_report_summary(
period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters
)
if filters.get("selected_view") == "Growth":
compute_growth_view_data(data, period_list)
if filters.get("selected_view") == "Margin":
compute_margin_view_data(data, period_list, filters.accumulated_values)
return columns, data, None, chart, report_summary, primitive_summary
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
accounts = get_accounts(filters.company, root_type)
if not accounts:
return None
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
company_currency = get_appropriate_currency(filters.company, filters)
gl_entries_by_account = {}
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account)
calculate_values(
accounts_by_name,
gl_entries_by_account,
period_list,
filters.accumulated_values,
False,
)
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
out = prepare_data(
accounts,
balance_must_be,
period_list,
company_currency,
accumulated_values=filters.accumulated_values,
)
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
if out:
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
return out
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account):
from erpnext.accounts.report.trial_balance.trial_balance import (
_extra_gl_conditions,
_fetch_gl_rows_duckdb,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
company = filters.company
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
if not leaf_accounts:
return
extra_cond, extra_params = _extra_gl_conditions(filters)
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
base_conds = [
"company = ?",
"is_cancelled = 0",
f"account IN ({account_placeholders})",
"voucher_type != 'Period Closing Voucher'",
]
base_params = [company, *leaf_accounts]
base_conds.extend(extra_cond)
base_params.extend(extra_params)
for period in period_list:
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
period_params = [*base_params, period.from_date, period.to_date]
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(period_entries, get_currency(filters))
for entry in period_entries:
entry.posting_date = period.to_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)

View File

@@ -7,13 +7,18 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2017-02-24 20:12:33.520866",
"modified": "2026-07-01 17:32:21.801141",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
"owner": "Administrator",
"ref_doctype": "GL Entry",
"report_name": "Trial Balance",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"report_type": "Script Report",
"roles": [
{
@@ -25,5 +30,7 @@
{
"role": "Auditor"
}
]
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -510,3 +510,215 @@ def hide_group_accounts(data):
d.update(indent=0)
non_group_accounts_data.append(d)
return non_group_accounts_data
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):
validate_filters(filters)
columns = get_columns()
data = get_data_duckdb(filters, conn)
return columns, data
else:
frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
def get_data_duckdb(filters, conn):
# accounts and all metadata via frappe.db — only GL Entry comes from DuckDB
accounts = frappe.db.sql(
"""select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
from `tabAccount` where company=%s order by lft""",
filters.company,
as_dict=True,
)
if not accounts:
return None
company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company)
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening)
opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening)
calculate_values(
accounts,
gl_entries_by_account,
opening_balances,
filters.get("show_net_values"),
ignore_is_opening=ignore_is_opening,
)
accumulate_values_into_parents(accounts, accounts_by_name)
data = prepare_data(accounts, filters, parent_children_map, company_currency)
return filter_out_zero_value_rows(
data, parent_children_map, show_zero_values=filters.get("show_zero_values")
)
def _extra_gl_conditions(filters):
"""Returns (conditions, params) for optional shared GL Entry filters."""
conditions, params = [], []
if filters.get("cost_center"):
cc = get_cost_centers_with_children(filters.get("cost_center"))
conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})")
params.extend(cc)
if filters.get("project"):
proj = filters.project if isinstance(filters.project, list) else [filters.project]
conditions.append(f"project IN ({', '.join(['?'] * len(proj))})")
params.extend(proj)
if frappe.db.count("Finance Book"):
company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book")
if filters.get("include_default_book_entries"):
if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb):
frappe.throw(
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
)
fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""]
else:
fb_list = [cstr(filters.get("finance_book")), ""]
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)")
params.extend(fb_list)
for dim in get_accounting_dimensions(as_list=False):
if filters.get(dim.fieldname):
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
filters[dim.fieldname] = get_dimension_with_children(
dim.document_type, filters.get(dim.fieldname)
)
vals = (
filters[dim.fieldname]
if isinstance(filters[dim.fieldname], list)
else [filters[dim.fieldname]]
)
conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})")
params.extend(vals)
return conditions, params
def _fetch_gl_rows_duckdb(conn, conditions, params):
cols = [
"account",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
"account_currency",
]
sql = f"""SELECT account, SUM(debit), SUM(credit),
SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency
FROM "tabGL Entry" WHERE {" AND ".join(conditions)}
GROUP BY account, account_currency"""
return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()]
def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening):
conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"]
params = [filters.company, filters.from_date, filters.to_date]
if not ignore_is_opening:
conditions.append("is_opening = 'No'")
if not flt(filters.get("with_period_closing_entry_for_current_period")):
conditions.append("voucher_type != 'Period Closing Voucher'")
extra_cond, extra_params = _extra_gl_conditions(filters)
conditions.extend(extra_cond)
params.extend(extra_params)
entries = _fetch_gl_rows_duckdb(conn, conditions, params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(entries, get_currency(filters))
gl_entries_by_account = {}
for entry in entries:
gl_entries_by_account.setdefault(entry.account, []).append(entry)
return gl_entries_by_account
def get_opening_balances_duckdb(conn, filters, ignore_is_opening):
bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening)
pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening)
bs.update(pl)
return bs
def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening):
accounting_dimensions = get_accounting_dimensions(as_list=False)
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
last_pcv = ""
if not ignore_closing_balances:
last_pcv = frappe.db.get_all(
"Period Closing Voucher",
filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)},
fields=["period_end_date", "name"],
order_by="period_end_date desc",
limit=1,
)
if last_pcv:
# Account Closing Balance fetched via frappe (not GL Entry)
gle = get_opening_balance(
"Account Closing Balance",
filters,
report_type,
accounting_dimensions,
period_closing_voucher=last_pcv[0].name,
ignore_is_opening=ignore_is_opening,
)
if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)):
start_date = add_days(last_pcv[0].period_end_date, 1)
gle += _get_gl_entry_opening_duckdb(
conn, filters, report_type, ignore_is_opening, start_date=start_date
)
else:
gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening)
opening = frappe._dict()
for d in gle:
opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0})
opening[d.account]["opening_debit"] += flt(d.debit)
opening[d.account]["opening_credit"] += flt(d.credit)
return opening
def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None):
accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name")
if not accounts:
return []
conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"]
params = [filters.company, *accounts]
if start_date:
conditions.append("posting_date >= ? AND posting_date < ?")
params.extend([start_date, filters.from_date])
if not ignore_is_opening:
conditions.append("is_opening = 'No'")
elif not ignore_is_opening:
conditions.append("(posting_date < ? OR is_opening = 'Yes')")
params.append(filters.from_date)
else:
conditions.append("posting_date < ?")
params.append(filters.from_date)
if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss":
conditions.append("posting_date >= ?")
params.append(filters.year_start_date)
if not flt(filters.get("with_period_closing_entry_for_opening")):
conditions.append("voucher_type != 'Period Closing Voucher'")
extra_cond, extra_params = _extra_gl_conditions(filters)
conditions.extend(extra_cond)
params.extend(extra_params)
gle = _fetch_gl_rows_duckdb(conn, conditions, params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(gle, get_currency(filters))
return gle

View File

@@ -74,6 +74,15 @@ frappe.ui.form.on("Asset Repair", {
};
};
}
if (frm.doc.asset) {
frappe.db.get_value("Asset", frm.doc.asset, "status").then(({ message }) => {
frm.set_df_property(
"capitalize_repair_cost",
"read_only",
message && message.status === "Fully Depreciated"
);
});
}
},
repair_status: (frm) => {

View File

@@ -140,7 +140,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Asset",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
"options": "Asset",
"reqd": 1
},
@@ -251,7 +251,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-01-06 15:48:13.862505",
"modified": "2026-07-14 10:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair",

View File

@@ -77,12 +77,15 @@ class AssetRepair(AccountsController):
)
def validate_asset(self):
if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"):
if self.asset_doc.status in ("Sold", "Scrapped"):
frappe.throw(
_("Asset {0} is in {1} status and cannot be repaired.").format(
get_link_to_form("Asset", self.asset), self.asset_doc.status
)
)
if self.asset_doc.get_status() == "Fully Depreciated":
self.capitalize_repair_cost = 0
self.increase_in_asset_life = 0
def validate_dates(self):
if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)):

View File

@@ -307,6 +307,7 @@
"fieldname": "net_rate",
"fieldtype": "Currency",
"label": "Net Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
@@ -613,7 +614,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-17 12:05:52.441645",
"modified": "2026-07-15 10:33:24.855979",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",

View File

@@ -141,6 +141,26 @@ class AccountsController(TransactionBase):
if self.doctype in relevant_docs:
self.set_payment_schedule()
def before_insert(self):
self.clear_clearance_date_on_amend()
def clear_clearance_date_on_amend(self):
"""Drop the bank reconciliation clearance date copied over while amending.
The framework copies `no_copy` fields when amending, so a reconciled
voucher would carry a stale clearance date into its amendment even though
the linked bank transaction gets unreconciled on cancellation.
"""
if not self.get("amended_from"):
return
if self.meta.has_field("clearance_date"):
self.clearance_date = None
for payment in self.get("payments") or []:
if payment.meta.has_field("clearance_date"):
payment.clearance_date = None
def remove_bundle_for_non_stock_invoices(self):
has_sabb = False
if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock:

View File

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

View File

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

View File

@@ -263,6 +263,10 @@ class StockController(AccountsController):
parent_details = self.get_parent_details_for_packed_items()
for row in self.get(table_name):
item_code = row.get("rm_item_code") or row.get("item_code")
if not item_code or not self.is_serial_batch_item(item_code):
continue
if (
not via_landed_cost_voucher
and row.serial_and_batch_bundle
@@ -1509,9 +1513,10 @@ class StockController(AccountsController):
@frappe.whitelist()
def show_accounting_ledger_preview(company, doctype, docname):
def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
filters = frappe._dict(company=company, include_dimensions=1)
doc = frappe.get_doc(doctype, docname)
doc.check_permission("read")
doc.run_method("before_gl_preview")
gl_columns, gl_data = get_accounting_ledger_preview(doc, filters)
@@ -1522,9 +1527,10 @@ def show_accounting_ledger_preview(company, doctype, docname):
@frappe.whitelist()
def show_stock_ledger_preview(company, doctype, docname):
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
filters = frappe._dict(company=company)
doc = frappe.get_doc(doctype, docname)
doc.check_permission("read")
doc.run_method("before_sl_preview")
sl_columns, sl_data = get_stock_ledger_preview(doc, filters)

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ from frappe import _
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.model.document import Document
from erpnext.crm.frappe_crm_api import is_crm_installed
class CRMSettings(Document):
# begin: auto-generated types
@@ -46,13 +48,16 @@ class CRMSettings(Document):
)
def validate_allowed_users(self):
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
if self.enable_frappe_crm_data_synchronization and not (is_crm_installed() or self.allowed_users):
frappe.throw(
_(
"Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
)
)
if self.enable_frappe_crm_data_synchronization and is_crm_installed() and self.allowed_users:
frappe.throw(_("Allowed Users is not required as Frappe CRM is already installed on the site."))
def before_save(self):
self.clear_allowed_users()

View File

@@ -1,5 +1,6 @@
import json
import click
import frappe
from frappe import _
@@ -150,7 +151,9 @@ def create_customer(customer_data=None):
for field in CUSTOMER_ALLOWED_FIELDS:
if customer_data.get(field) is not None:
customer.set(field, customer_data.get(field))
customer.insert(ignore_permissions=True)
# If CRM is installed on the site, User Permission cannot be ignored while saving Customer Records.
customer.insert(ignore_permissions=not is_crm_installed())
customer_name = customer.name
contacts = json.loads(customer_data.get("contacts"))
@@ -169,6 +172,10 @@ def validate_frappe_crm_sync():
_("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
)
# Skip allowed_users validation if CRM is installed on the site.
if is_crm_installed():
return
allowed_users = [d.user for d in CRMSettings.allowed_users]
if frappe.session.user not in allowed_users:
@@ -178,3 +185,35 @@ def validate_frappe_crm_sync():
),
exc=frappe.PermissionError,
)
def is_crm_installed():
return "crm" in frappe.get_installed_apps()
def remove_allowed_users_on_crm_install():
try:
CRMSettings = frappe.get_single("CRM Settings")
if not CRMSettings.enable_frappe_crm_data_synchronization:
return
CRMSettings.allowed_users = []
CRMSettings.save()
click.secho("Removed 'Allowed Users' from CRM Settings.")
except Exception:
click.secho("'Allowed Users' from CRM Settings couldn't be cleared.")
def disable_frappe_crm_data_synchronization_on_crm_uninstall():
try:
CRMSettings = frappe.get_single("CRM Settings")
if not CRMSettings.enable_frappe_crm_data_synchronization:
return
CRMSettings.enable_frappe_crm_data_synchronization = 0
CRMSettings.save()
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings has been disabled.")
except Exception:
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings could not be disabled.")

View File

@@ -166,6 +166,7 @@ def get_open_todos(ref_doctype, ref_docname):
"allocated_to",
"date",
],
order_by="date asc",
)
@@ -190,6 +191,7 @@ def get_open_events(ref_doctype, ref_docname):
& (event_link.reference_docname == ref_docname)
& (event.status == "Open")
)
.orderby(event.starts_on)
)
data = query.run(as_dict=True)

View File

@@ -61,6 +61,9 @@ before_install = [
]
after_install = "erpnext.setup.install.after_install"
after_app_install = "erpnext.setup.install.after_app_install"
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
boot_session = "erpnext.startup.boot.boot_session"
notification_config = "erpnext.startup.notifications.get_notification_config"
get_help_messages = "erpnext.utilities.activation.get_help_messages"

View File

@@ -441,7 +441,11 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
// Refetch operations whenever the routing is (re)selected, so that
// changing the routing - e.g. on a new BOM version copied from another
// BOM - replaces the operations with those of the newly selected routing
// instead of keeping the old ones.
if (frm.doc.routing && frm.doc.with_operations) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

@@ -347,6 +347,49 @@ class TestJobCard(FrappeTestCase):
job_card.reload()
self.assertEqual(job_card.transferred_qty, 0.0)
def test_work_order_transferred_qty_with_multiple_job_cards(self):
create_bom_with_multiple_operations()
work_order = make_wo_with_transfer_against_jc()
self.generate_required_stock(work_order)
job_cards = frappe.get_all(
"Job Card",
filters={"work_order": work_order.name},
pluck="name",
order_by="sequence_id",
)
completed_qty = (4, 3)
for job_card_name, qty in zip(job_cards, completed_qty, strict=True):
job_card = frappe.get_doc("Job Card", job_card_name)
job_card.for_quantity = qty
job_card.save()
transfer_entry = make_stock_entry_from_jc(job_card.name)
transfer_entry.fg_completed_qty = qty
transfer_entry.get_items()
transfer_entry.submit()
job_card.reload()
job_card.append(
"time_logs",
{
"from_time": now(),
"to_time": add_to_date(now(), hours=1),
"completed_qty": qty,
},
)
job_card.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty))
# Refreshing required items must not replace the Job Card roll-up with the sum
# of FG quantities from Material Transfer Stock Entries (4 + 3).
work_order.update_required_items()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty))
def test_job_card_material_transfer_correctness(self):
"""
1. Test if only current Job Card Items are pulled in a Stock Entry against a Job Card

View File

@@ -687,6 +687,28 @@ class TestWorkOrder(FrappeTestCase):
ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
@change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
# "_Test Item Group" is master data with buying_cost_center already set to
# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
# BOM need to be created, since no existing item in that group has one.
fg_item = make_item(
"_Test FG Item For Item Group Cost Center",
{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
)
if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
make_bom(item=fg_item.name, raw_materials=["_Test Item"])
wo_order = make_wo_order_test_record(
production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
)
ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
ste.insert()
fg_row = next(d for d in ste.items if d.is_finished_item)
self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
def test_operation_time_with_batch_size(self):
fg_item = "Test Batch Size Item For BOM"
rm1 = "Test Batch Size Item RM 1 For BOM"
@@ -1523,6 +1545,38 @@ class TestWorkOrder(FrappeTestCase):
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
def test_status_in_process_when_only_one_required_item_transferred(self):
"""Stock Entry created from a Pick List that picked only one of the required items:
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
still move to In Process because material is already in WIP."""
from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
)
pick_list = create_pick_list(work_order.name, for_qty=work_order.qty)
# pick only _Test Item; the other required item is left out of this pick list
pick_list.pick_manually = 1
pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"]
pick_list.save()
pick_list.submit()
stock_entry = frappe.get_doc(create_stock_entry(frappe.as_json(pick_list.as_dict())))
self.assertEqual(stock_entry.fg_completed_qty, 0.0)
stock_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",

View File

@@ -157,6 +157,7 @@ class WorkOrder(Document):
self.check_wip_warehouse_skip()
self.calculate_operating_cost()
self.validate_qty()
self.validate_dates()
self.validate_transfer_against()
self.validate_operations()
self.status = self.get_status()
@@ -175,6 +176,11 @@ class WorkOrder(Document):
self.validate_operations_sequence()
def validate_dates(self):
if self.planned_start_date and self.planned_end_date:
if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date):
frappe.throw(_("Planned End Date cannot be before Planned Start Date"))
def validate_operations_sequence(self):
if all([not op.sequence_id for op in self.operations]):
for op in self.operations:
@@ -406,7 +412,11 @@ class WorkOrder(Document):
elif self.docstatus == 1:
if status not in ["Closed", "Stopped"]:
status = "Not Started"
if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer:
if (
flt(self.material_transferred_for_manufacturing) > 0
or self.skip_transfer
or self.has_transferred_material()
):
status = "In Process"
precision = frappe.get_precision("Work Order", "produced_qty")
@@ -425,6 +435,26 @@ class WorkOrder(Document):
return status
def has_transferred_material(self):
"""True if any raw material was transferred against this work order via a pick list
(these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
qty = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(Sum(ste_child.transfer_qty))
.where(
(ste.work_order == self.name)
& (ste.docstatus == 1)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
& (ste.pick_list.isnotnull())
)
).run()[0][0]
return flt(qty) > 0
def update_work_order_qty(self):
"""Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
based on Stock Entry"""
@@ -1245,6 +1275,10 @@ class WorkOrder(Document):
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
# Job Card transfers use the minimum completed quantity across operations.
if self.operations and self.transfer_material_against == "Job Card":
return
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")

View File

@@ -262,7 +262,6 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta
erpnext.patches.v14_0.update_proprietorship_to_individual
erpnext.patches.v15_0.rename_subcontracting_fields
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
erpnext.patches.v16_0.create_company_custom_fields
[post_model_sync]
erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets
@@ -422,6 +421,7 @@ execute:frappe.db.set_single_value("Accounts Settings", "fetch_valuation_rate_fo
erpnext.patches.v15_0.add_company_payment_gateway_account
erpnext.patches.v15_0.update_uae_zero_rated_fetch
erpnext.patches.v15_0.update_fieldname_in_accounting_dimension_filter
erpnext.patches.v16_0.create_company_custom_fields
erpnext.patches.v15_0.set_asset_status_if_not_already_set
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1)
@@ -438,3 +438,8 @@ erpnext.patches.v16_0.migrate_address_contact_custom_fields
erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
erpnext.patches.v16_0.access_control_for_project_users

View File

@@ -0,0 +1,21 @@
import frappe
def execute():
for custom_field in frappe.get_all(
"Custom Field",
filters={
"fieldname": "service_level_agreement",
"fieldtype": "Link",
"options": "Service Level Agreement",
"link_filters": ("is", "not set"),
},
fields=["name", "dt"],
):
link_filters = frappe.as_json(
[["Service Level Agreement", "document_type", "=", custom_field.dt]], indent=None
)
frappe.db.set_value(
"Custom Field", custom_field.name, "link_filters", link_filters, update_modified=False
)
frappe.clear_cache(doctype=custom_field.dt)

View File

@@ -0,0 +1,20 @@
import frappe
def execute():
for docfield in frappe.get_all(
"DocField",
filters={
"parenttype": "DocType",
"fieldname": "service_level_agreement",
"fieldtype": "Link",
"options": "Service Level Agreement",
"link_filters": ("is", "not set"),
},
fields=["name", "parent"],
):
link_filters = frappe.as_json(
[["Service Level Agreement", "document_type", "=", docfield.parent]], indent=None
)
frappe.db.set_value("DocField", docfield.name, "link_filters", link_filters, update_modified=False)
frappe.clear_cache(doctype=docfield.parent)

View File

@@ -0,0 +1,33 @@
import frappe
def execute():
Project = frappe.qb.DocType("Project")
ProjectUser = frappe.qb.DocType("Project User")
query = (
frappe.qb.from_(Project)
.join(ProjectUser)
.on(Project.name == ProjectUser.parent)
.select(Project.name, ProjectUser.user)
)
proj_users = query.run(as_dict=1)
project_mapped_users = get_project_mapped_users(proj_users)
for d in proj_users:
if d.user in project_mapped_users[d.name]:
continue
frappe.share.add_docshare("Project", d.name, user=d.user)
def get_project_mapped_users(proj_users):
projects = set([d.name for d in proj_users])
project_mapped_users = {}
for d in projects:
project_mapped_users[d] = [d.user for d in frappe.share.get_users("Project", d)]
return project_mapped_users

View File

@@ -0,0 +1,58 @@
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import flt
def execute():
StockEntry = frappe.qb.DocType("Stock Entry")
StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
pick_lists = (
frappe.qb.from_(StockEntry)
.select(StockEntry.pick_list)
.distinct()
.where((StockEntry.pick_list.isnotnull()) & (StockEntry.docstatus == 1))
).run(pluck=True)
if not pick_lists:
return
rows = (
frappe.qb.from_(StockEntryDetail)
.join(StockEntry)
.on(StockEntryDetail.parent == StockEntry.name)
.select(
StockEntry.pick_list,
StockEntryDetail.item_code,
StockEntryDetail.s_warehouse,
Sum(StockEntryDetail.transfer_qty).as_("qty"),
)
.where((StockEntry.pick_list.isin(pick_lists)) & (StockEntry.docstatus == 1))
.groupby(StockEntry.pick_list, StockEntryDetail.item_code, StockEntryDetail.s_warehouse)
).run(as_dict=True)
transferred = {(r.pick_list, r.item_code, r.s_warehouse): flt(r.qty) for r in rows}
items = frappe.get_all(
"Pick List Item",
filters={"parent": ("in", pick_lists), "picked_qty": (">", 0)},
fields=["name", "parent", "item_code", "warehouse", "picked_qty"],
order_by="idx",
)
updates = {}
for row in items:
key = (row.parent, row.item_code, row.warehouse)
available = transferred.get(key, 0)
if available <= 0:
continue
qty = min(flt(row.picked_qty), available)
transferred[key] = available - qty
updates[row.name] = {"transferred_qty": qty}
if not updates:
return
frappe.db.auto_commit_on_many_writes = True
frappe.db.bulk_update("Pick List Item", updates)
frappe.db.auto_commit_on_many_writes = False

View File

@@ -0,0 +1,10 @@
import frappe
def execute():
from erpnext.crm.frappe_crm_api import is_crm_installed, remove_allowed_users_on_crm_install
if not is_crm_installed():
return
remove_allowed_users_on_crm_install()

View File

@@ -83,7 +83,7 @@
"no_copy": 1,
"oldfieldname": "status",
"oldfieldtype": "Select",
"options": "Open\nCompleted\nCancelled",
"options": "Open\nOn hold\nCompleted\nCancelled",
"search_index": 1
},
{
@@ -205,13 +205,15 @@
"fieldname": "users",
"fieldtype": "Table",
"label": "Users",
"options": "Project User"
"options": "Project User",
"permlevel": 1
},
{
"fieldname": "copied_from",
"fieldtype": "Data",
"hidden": 1,
"label": "Copied From",
"permlevel": 1,
"read_only": 1
},
{
@@ -464,13 +466,25 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2026-05-22 16:45:50.762759",
"modified": "2026-07-14 14:32:11.328347",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"delete": 1,
"email": 1,
"export": 1,
"permlevel": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Projects Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,

View File

@@ -61,7 +61,7 @@ class Project(Document):
project_type: DF.Link | None
sales_order: DF.Link | None
second_email: DF.Time | None
status: DF.Literal["Open", "Completed", "Cancelled"]
status: DF.Literal["Open", "On hold", "Completed", "Cancelled"]
subject: DF.Data | None
to_time: DF.Time | None
total_billable_amount: DF.Currency
@@ -93,6 +93,7 @@ class Project(Document):
def validate(self):
if not self.is_new():
self.copy_from_template() # nosemgrep
self.control_access_for_project_users()
self.send_welcome_email()
self.update_costing()
self.update_percent_complete()
@@ -207,6 +208,7 @@ class Project(Document):
self.copy_from_template() # nosemgrep
if self.sales_order:
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
self.control_access_for_project_users()
def on_trash(self):
frappe.db.set_value("Sales Order", {"project": self.name}, "project", "")
@@ -264,8 +266,8 @@ class Project(Document):
pct_complete += row["progress"] * frappe.utils.safe_div(row["task_weight"], weight_sum)
self.percent_complete = flt(flt(pct_complete), 2)
# don't update status if it is cancelled
if self.status == "Cancelled":
# don't update status if it is manually set to cancelled or on hold
if self.status in ("Cancelled", "On hold"):
return
self.status = "Completed" if self.percent_complete == 100 else "Open"
@@ -377,6 +379,34 @@ class Project(Document):
)
user.welcome_email_sent = 1
def control_access_for_project_users(self):
def revoke_access_for_project_users(removed_users):
users = set([d.user for d in frappe.share.get_users(self.doctype, self.name)])
for user in removed_users:
if user not in users:
continue
frappe.share.remove(self.doctype, self.name, user)
def grant_access_for_project_users(new_users):
for user in new_users:
frappe.share.add_docshare(self.doctype, self.name, user=user)
current_users = set([d.user for d in self.users])
old_doc = self.get_doc_before_save()
if not old_doc:
grant_access_for_project_users(current_users)
return
previous_users = set([d.user for d in old_doc.users])
new_users = current_users - previous_users
removed_users = previous_users - current_users
revoke_access_for_project_users(removed_users)
grant_access_for_project_users(new_users)
def get_timeline_data(doctype: str, name: str) -> dict[int, int]:
"""Return timeline for attendance"""

View File

@@ -4,6 +4,8 @@ frappe.listview_settings["Project"] = {
get_indicator: function (doc) {
if (doc.status == "Open" && doc.percent_complete) {
return [__("{0}%", [cint(doc.percent_complete)]), "orange", "percent_complete,>,0|status,=,Open"];
} else if (doc.status == "On hold") {
return [__("On hold"), "blue", "status,=,On hold"];
} else {
return [__(doc.status), frappe.utils.guess_colour(doc.status), "status,=," + doc.status];
}

View File

@@ -227,6 +227,61 @@ class TestProject(FrappeTestCase):
project.save()
self.assertEqual(project.status, "Completed")
def _create_portal_user(self, email):
"""A user with no Project-related role, so read access can only come from
control_access_for_project_users() sharing the doc with them."""
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "Portal",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
return email
def test_new_project_grants_access_to_its_users(self):
member = self._create_portal_user(f"new_proj_member_{frappe.generate_hash(length=6)}@example.com")
project = frappe.get_doc(
doctype="Project",
project_name=f"_Test New Project Access {frappe.generate_hash(length=6)}",
status="Open",
company="_Test Company",
)
project.append("users", {"user": member, "welcome_email_sent": 1})
project.insert() # must not raise
self.assertTrue(project.has_permission(user=member))
shared_with = [d.user for d in frappe.share.get_users("Project", project.name)]
self.assertIn(member, shared_with)
def test_adding_and_removing_project_user_updates_access(self):
stays = self._create_portal_user(f"stays_{frappe.generate_hash(length=6)}@example.com")
leaves = self._create_portal_user(f"leaves_{frappe.generate_hash(length=6)}@example.com")
project = frappe.get_doc(
doctype="Project",
project_name=f"_Test Project User Membership {frappe.generate_hash(length=6)}",
status="Open",
company="_Test Company",
)
project.append("users", {"user": stays, "welcome_email_sent": 1})
project.insert()
self.assertTrue(project.has_permission(user=stays))
# adding a user on update (not insert) must also grant them access
project.append("users", {"user": leaves, "welcome_email_sent": 1})
project.save()
self.assertTrue(project.has_permission(user=leaves))
# removing a user must revoke the share that was granted for membership
project.users = [d for d in project.users if d.user != leaves]
project.save()
self.assertFalse(project.has_permission(user=leaves))
self.assertTrue(project.has_permission(user=stays))
def get_project(name, template):
project = frappe.get_doc(

View File

@@ -15,6 +15,11 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
this.warehouse_field = opts.warehouse_field || "warehouse";
// field name on row which defines max quantity to be scanned e.g. picklist
this.max_qty_field = opts.max_qty_field;
// row fields that, if set, mean max_qty_field is a real demand qty (e.g. from a
// linked Sales Order) that scanning must not exceed. Rows with none of these set
// have no real demand qty, so max_qty_field is just an arbitrary default and
// shouldn't cap further scans.
this.demand_ref_fields = opts.demand_ref_fields || [];
// scanner won't add a new row if this flag is set.
this.dont_allow_new_row = opts.dont_allow_new_row;
// scanner will ask user to type the quantity instead of incrementing by 1
@@ -390,6 +395,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
async set_barcode_uom(row, uom) {
// e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental
// barcode uom must not overwrite the row's own uom.
if (this.max_qty_field) return;
if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) {
await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom);
}
@@ -456,8 +464,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
const matching_row = (row) => {
const item_match = row.item_code == item_code;
const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no;
const uom_match = !uom || row[this.uom_field] == uom;
const qty_in_limit = flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom;
const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]);
const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
const item_scanned = row.has_item_scanned;
let warehouse_match = true;

View File

@@ -280,19 +280,17 @@ erpnext.sales_common = {
set_actual_qty(doc, cdt, cdn) {
let row = locals[cdt][cdn];
let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order"];
let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order", "Quotation"];
if (row.item_code && row.warehouse && sales_doctypes.includes(doc.doctype)) {
frappe.call({
return this.frm.call({
method: "erpnext.stock.get_item_details.get_bin_details",
child: row,
args: {
item_code: row.item_code,
warehouse: row.warehouse,
},
callback(r) {
if (r.message) {
frappe.model.set_value(cdt, cdn, "actual_qty", r.message.actual_qty);
}
company: doc.company,
include_child_warehouses: true,
},
});
}

View File

@@ -4,6 +4,8 @@
import frappe
from frappe import _
from frappe.query_builder import Case, CustomFunction
from frappe.query_builder.functions import Count, Max, Sum
from frappe.utils import cint
@@ -24,50 +26,69 @@ def execute(filters=None):
customers = get_sales_details(doctype)
data = []
for cust in customers:
if cint(cust[8]) >= cint(days_since_last_order):
cust.insert(7, get_last_sales_amt(cust[0], doctype))
data.append(cust)
for row in customers:
if cint(row[8]) >= cint(days_since_last_order):
row.insert(7, get_last_sales_amt(row[0], doctype))
data.append(row)
return columns, data
def get_sales_details(doctype):
cond = """sum(so.base_net_total) as 'total_order_considered',
max(so.posting_date) as 'last_order_date',
DATEDIFF(CURRENT_DATE, max(so.posting_date)) as 'days_since_last_order' """
if doctype == "Sales Order":
cond = """sum(if(so.status = "Stopped",
so.base_net_total * so.per_delivered/100,
so.base_net_total)) as 'total_order_considered',
max(so.transaction_date) as 'last_order_date',
DATEDIFF(CURRENT_DATE, max(so.transaction_date)) as 'days_since_last_order'"""
customer = frappe.qb.DocType("Customer")
sales_doctype = frappe.qb.DocType(doctype)
return frappe.db.sql(
f"""select
cust.name,
cust.customer_name,
cust.territory,
cust.customer_group,
count(distinct(so.name)) as 'num_of_order',
sum(base_net_total) as 'total_order_value', {cond}
from `tabCustomer` cust, `tab{doctype}` so
where cust.name = so.customer and so.docstatus = 1
group by cust.name
order by 'days_since_last_order' desc """,
as_list=1,
)
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
current_date = CustomFunction("CURRENT_DATE", [])
if doctype == "Sales Order":
total_considered = Sum(
Case()
.when(
sales_doctype.status == "Stopped",
sales_doctype.base_net_total * sales_doctype.per_delivered / 100,
)
.else_(sales_doctype.base_net_total)
)
date_col = sales_doctype.transaction_date
else:
total_considered = Sum(sales_doctype.base_net_total)
date_col = sales_doctype.posting_date
last_order_date = Max(date_col)
days_since_last_order = date_diff(current_date(), last_order_date)
return (
frappe.qb.from_(customer)
.inner_join(sales_doctype)
.on(customer.name == sales_doctype.customer)
.select(
customer.name,
customer.customer_name,
customer.territory,
customer.customer_group,
Count(sales_doctype.name).distinct().as_("num_of_order"),
Sum(sales_doctype.base_net_total).as_("total_order_value"),
total_considered.as_("total_order_considered"),
last_order_date.as_("last_order_date"),
days_since_last_order.as_("days_since_last_order"),
)
.where(sales_doctype.docstatus == 1)
.groupby(customer.name)
.orderby(days_since_last_order, order=frappe.qb.desc)
).run(as_list=True)
def get_last_sales_amt(customer, doctype):
cond = "posting_date"
if doctype == "Sales Order":
cond = "transaction_date"
res = frappe.db.sql(
f"""select base_net_total from `tab{doctype}`
where customer = %s and docstatus = 1 order by {cond} desc
limit 1""",
customer,
)
sales_doctype = frappe.qb.DocType(doctype)
date_col = sales_doctype.transaction_date if doctype == "Sales Order" else sales_doctype.posting_date
res = (
frappe.qb.from_(sales_doctype)
.select(sales_doctype.base_net_total)
.where((sales_doctype.customer == customer) & (sales_doctype.docstatus == 1))
.orderby(date_col, order=frappe.qb.desc)
.limit(1)
).run()
return res and res[0][0] or 0

View File

@@ -0,0 +1,55 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, getdate, today
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.inactive_customers.inactive_customers import execute
class TestInactiveCustomers(FrappeTestCase):
def setUp(self):
self.customer = frappe.new_doc("Customer")
self.customer.customer_name = "_Test Inactive Customer"
self.customer.customer_group = "_Test Customer Group"
self.customer.insert()
self.last_order_date = add_days(today(), -120)
so = make_sales_order(
customer=self.customer.name,
transaction_date=self.last_order_date,
qty=5,
rate=200,
)
so.submit()
self.sales_order = so
def test_invalid_doctype_is_rejected(self):
self.assertRaises(
frappe.ValidationError,
execute,
{"doctype": "Purchase Order", "days_since_last_order": 30},
)
def test_inactive_customer_is_listed_with_expected_columns(self):
columns, data = execute({"doctype": "Sales Order", "days_since_last_order": 30})
row = self.get_customer_row(data)
self.assertIsNotNone(row, "Inactive customer should be present in the report")
# Column contract: the report relies on positional access.
self.assertEqual(row[0], self.customer.name)
self.assertEqual(row[7], 1000) # Last Order Amount inserted at index 7 (5 * 200)
self.assertEqual(getdate(row[8]), getdate(self.last_order_date)) # Last Order Date
self.assertGreaterEqual(row[9], 30) # Days Since Last Order
def test_recent_customer_is_excluded(self):
_columns, data = execute({"doctype": "Sales Order", "days_since_last_order": 200})
self.assertIsNone(
self.get_customer_row(data),
"Customer ordering within the threshold must be excluded",
)
def get_customer_row(self, data):
return next((row for row in data if row[0] == self.customer.name), None)

View File

@@ -4,7 +4,9 @@
import frappe
from frappe import _, msgprint, qb
from frappe.desk.reportview import build_match_conditions
from frappe.query_builder import Case, Criterion
from pypika.terms import LiteralValue
from erpnext import get_company_currency
@@ -183,8 +185,22 @@ def get_entries(filters):
.as_("contribution_amt")
)
# Only pass valid document-field filters to get_query; report-specific keys such as
# doc_type / sales_person / item_group are handled separately below.
doc_filters = {"docstatus": 1}
for field in ["company", "customer", "territory"]:
if filters.get(field):
doc_filters[field] = filters.get(field)
if filters.get("from_date") and filters.get("to_date"):
doc_filters[date_field] = ["between", [filters.get("from_date"), filters.get("to_date")]]
elif filters.get("from_date"):
doc_filters[date_field] = [">=", filters.get("from_date")]
elif filters.get("to_date"):
doc_filters[date_field] = ["<=", filters.get("to_date")]
query = (
frappe.get_query(dt, filters=filters, ignore_permissions=False)
frappe.get_query(dt, filters=doc_filters)
.join(dt_item)
.on(dt.name == dt_item.parent)
.join(st)
@@ -203,48 +219,34 @@ def get_entries(filters):
contribution_amt_case,
)
.where(st.parenttype == doc_type)
.where(dt.docstatus == 1)
)
if filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
sp = frappe.qb.DocType("Sales Person")
query = query.where(
st.sales_person.isin(frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt)))
)
# only resolve items when an item_group/brand filter is set; otherwise get_items
# would return every item in the system and add a huge IN() clause on each run
if filters.get("item_group") or filters.get("brand"):
items = get_items(filters)
if not items:
# the item_group/brand filter matched nothing -> no rows
return []
query = query.where(dt_item.item_code.isin([d[0] for d in items]))
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
# Apply user permissions (v15: ignore_permissions is not available)
match_conditions = build_match_conditions(doc_type)
if match_conditions:
query = query.where(LiteralValue(match_conditions))
return query.run(as_dict=True)
def get_conditions(filters, date_field):
conditions = [""]
values = []
for field in ["company", "customer", "territory"]:
if filters.get(field):
conditions.append(f"dt.{field}=%s")
values.append(filters[field])
if filters.get("sales_person"):
lft, rgt = frappe.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
conditions.append(
f"exists(select name from `tabSales Person` where lft >= {lft} and rgt <= {rgt} and name=st.sales_person)"
)
if filters.get("from_date"):
conditions.append(f"dt.{date_field}>=%s")
values.append(filters["from_date"])
if filters.get("to_date"):
conditions.append(f"dt.{date_field}<=%s")
values.append(filters["to_date"])
items = get_items(filters)
if items:
conditions.append("dt_item.item_code in (%s)" % ", ".join(["%s"] * len(items)))
values += items
else:
# return empty result, if no items are fetched after filtering on 'item group' and 'brand'
conditions.append("dt_item.item_code = Null")
return " and ".join(conditions), values
def get_items(filters):
item = qb.DocType("Item")
@@ -269,8 +271,5 @@ def get_items(filters):
def get_item_details():
item_details = {}
for d in frappe.db.sql("""SELECT `name`, `item_group`, `brand` FROM `tabItem`""", as_dict=1):
item_details.setdefault(d.name, d)
return item_details
items = frappe.get_all("Item", fields=["name", "item_group", "brand"])
return {d.name: d for d in items}

View File

@@ -330,33 +330,48 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_cost_center",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "write_off_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Write Off Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "exchange_gain_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Exchange Gain / Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_exchange_gain_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Unrealized Exchange Gain/Loss Account",
"no_copy": 1,
"options": "Account"
},
{
@@ -482,6 +497,7 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "expenses_included_in_valuation",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@@ -490,15 +506,19 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "accumulated_depreciation_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Accumulated Depreciation Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_expense_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Depreciation Expense Account",
"no_copy": 1,
"options": "Account"
@@ -519,29 +539,39 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "disposal_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Gain/Loss Account on Asset Disposal",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_cost_center",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Asset Depreciation Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "capital_work_in_progress_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Capital Work In Progress Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "asset_received_but_not_billed",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Asset Received But Not Billed",
"no_copy": 1,
"options": "Account"
},
{
@@ -673,15 +703,21 @@
"options": "Warehouse"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_profit_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Unrealized Profit / Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "default_discount_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Payment Discount Account",
"no_copy": 1,
"options": "Account"
},
{
@@ -723,8 +759,10 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_received_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Advance Received Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
"no_copy": 1,
"options": "Account"
},
{
@@ -733,8 +771,10 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_paid_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Advance Paid Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
"no_copy": 1,
"options": "Account"
},
{
@@ -814,9 +854,12 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_for_opening",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off for Opening",
"no_copy": 1,
"options": "Account"
},
{
@@ -865,7 +908,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
"modified": "2025-11-16 16:51:27.624096",
"modified": "2026-07-02 07:21:21.794533",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",

View File

@@ -74,6 +74,7 @@ class Company(NestedSet):
default_operating_cost_account: DF.Link | None
default_payable_account: DF.Link | None
default_provisional_account: DF.Link | None
default_purchase_price_variance_account: DF.Link | None
default_receivable_account: DF.Link | None
default_sales_contact: DF.Link | None
default_selling_terms: DF.Link | None

View File

@@ -367,3 +367,19 @@ DEFAULT_ROLE_PROFILES = {
"Purchase Manager",
],
}
def after_app_install(app_name=None):
if app_name == "crm":
from erpnext.crm.frappe_crm_api import remove_allowed_users_on_crm_install
remove_allowed_users_on_crm_install()
def after_app_uninstall(app_name=None):
if app_name == "crm":
from erpnext.crm.frappe_crm_api import disable_frappe_crm_data_synchronization_on_crm_uninstall
disable_frappe_crm_data_synchronization_on_crm_uninstall()
frappe.db.commit() # nosemgrep

View File

@@ -9,6 +9,7 @@ from frappe import _
from frappe.contacts.doctype.address.address import get_company_address
from frappe.contacts.doctype.contact.contact import get_default_contact
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.query_builder import DocType
@@ -441,22 +442,34 @@ class DeliveryNote(SellingController):
frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"]))
def update_current_stock(self):
if self.get("_action") and self._action != "update_after_submit":
for d in self.get("items"):
d.actual_qty = frappe.db.get_value(
"Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty"
)
if not (self.get("_action") and self._action != "update_after_submit"):
return
for d in self.get("packed_items"):
bin_qty = frappe.db.get_value(
"Bin",
{"item_code": d.item_code, "warehouse": d.warehouse},
["actual_qty", "projected_qty"],
as_dict=True,
)
if bin_qty:
d.actual_qty = flt(bin_qty.actual_qty)
d.projected_qty = flt(bin_qty.projected_qty)
warehouse_item_codes = {}
for d in self.get("items") + self.get("packed_items"):
warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code)
if not warehouse_item_codes:
return
bin_map = {}
for warehouse, item_codes in warehouse_item_codes.items():
for b in frappe.get_all(
"Bin",
filters={"item_code": ["in", item_codes], "warehouse": warehouse},
fields=["item_code", "actual_qty", "projected_qty"],
):
bin_map[(b.item_code, warehouse)] = b
for d in self.get("items"):
bin_data = bin_map.get((d.item_code, d.warehouse))
d.actual_qty = bin_data.actual_qty if bin_data else None
for d in self.get("packed_items"):
bin_data = bin_map.get((d.item_code, d.warehouse))
if bin_data:
d.actual_qty = flt(bin_data.actual_qty)
d.projected_qty = flt(bin_data.projected_qty)
def on_submit(self):
self.validate_packed_qty()
@@ -910,7 +923,9 @@ def get_returned_qty_map(delivery_note):
@frappe.whitelist()
def make_sales_invoice(source_name, target_doc=None, args=None):
def make_sales_invoice(
source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None
):
if args is None:
args = {}
if isinstance(args, str):
@@ -1015,7 +1030,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None):
frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
if not doc.is_return:
if doc.is_return:
# A credit note made from a return Delivery Note should roll back the billed
# amount on the linked Sales Order too, so that per_billed stays consistent with
# per_delivered (which the return already reset).
doc.update_billed_amount_in_sales_order = True
else:
so, doctype, fieldname = doc.get_order_details()
if (
doc.linked_order_has_payment_terms(so, fieldname, doctype)

View File

@@ -2599,6 +2599,92 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(dn.per_returned, 100)
self.assertEqual(returned.status, "Return")
def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn):
"""Given a fully billed Sales Order and a submitted Delivery Note that delivers it,
a credit note made from the return of that Delivery Note must reset per_billed to 0
while leaving the delivery quantities exactly as the return already set them."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
so.load_from_db()
self.assertEqual(so.per_delivered, 100)
self.assertEqual(so.per_billed, 100)
return_dn = make_sales_return(dn.name)
return_dn.insert()
return_dn.submit()
# the return reverses the delivery quantities
so.load_from_db()
self.assertEqual(so.per_delivered, 0)
self.assertEqual(so.items[0].delivered_qty, 0)
credit_note = make_sales_invoice(return_dn.name)
self.assertTrue(credit_note.is_return)
self.assertTrue(credit_note.update_billed_amount_in_sales_order)
# A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the
# credit note only rolls back billing and never re-reverses the delivery quantities.
self.assertFalse(credit_note.update_stock)
credit_note.insert()
credit_note.submit()
# per_billed is reset, and the delivery state stays exactly as the return left it
so.load_from_db()
self.assertEqual(so.per_billed, 0)
self.assertEqual(so.per_delivered, 0)
self.assertEqual(so.items[0].delivered_qty, 0)
self.assertEqual(so.items[0].returned_qty, 0)
# Cancelling the credit note should restore the billed amount on the Sales Order.
credit_note.cancel()
so.load_from_db()
self.assertEqual(so.per_billed, 100)
def test_sales_order_per_billed_after_credit_note_from_return_dn(self):
# Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note.
# The DN carries si_detail in this path.
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
so = make_sales_order(qty=2)
si = make_si_from_so(so.name)
si.insert()
si.submit()
dn = make_delivery_note(si.name)
dn.insert()
dn.submit()
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self):
# SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note.
# SO per_billed rolls back via the status_updater in update_prevdoc_status.
from erpnext.selling.doctype.sales_order.sales_order import (
make_delivery_note as make_dn_from_so,
)
from erpnext.selling.doctype.sales_order.sales_order import (
make_sales_invoice as make_si_from_so,
)
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
so = make_sales_order(qty=2)
si = make_si_from_so(so.name)
si.insert()
si.submit()
dn = make_dn_from_so(so.name)
dn.insert()
dn.submit()
self.assertIsNone(dn.items[0].si_detail)
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
def test_sales_return_for_product_bundle(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return

View File

@@ -145,6 +145,7 @@
"ignore_user_permissions": 1,
"in_standard_filter": 1,
"label": "Variant Of",
"link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]",
"options": "Item",
"read_only": 1,
"search_index": 1,
@@ -897,7 +898,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-03-17 20:39:05.218344",
"modified": "2026-07-05 23:24:45.734144",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

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

View File

@@ -443,6 +443,100 @@ class TestItem(FrappeTestCase):
"Large",
)
def test_rename_attribute_abbr_updates_variant_item_code(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
variant.save()
attribute = frappe.get_doc("Item Attribute", "Test Size")
for row in attribute.item_attribute_values:
if row.attribute_value == "Large":
row.abbr = "LRG"
break
def restore_test_size_abbr():
doc = frappe.get_doc("Item Attribute", "Test Size")
for row in doc.item_attribute_values:
if row.attribute_value == "Large":
row.abbr = "L"
break
frappe.flags.attribute_values = None
doc.save()
self.addCleanup(restore_test_size_abbr)
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1))
frappe.flags.attribute_values = None
attribute.save()
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L"))
self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG"))
self.assertEqual(
frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"),
"_Test Variant Item-LRG",
)
def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self):
# item_name can be derived from the template's item_name, which may differ from its
# item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name
# must follow the abbreviation rename the same way item_code does.
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1)
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)
template = frappe.get_doc("Item", "_Test Variant Item").as_dict()
template = frappe.get_doc(
{
"doctype": "Item",
"item_code": "_Test Variant Item Diff",
"item_name": "Test Variant Friendly Name",
"item_group": template.item_group,
"stock_uom": template.stock_uom,
"has_variants": 1,
"attributes": [{"attribute": "Test Size"}],
}
)
template.insert()
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1))
variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"})
variant.save()
self.assertEqual(variant.item_code, "_Test Variant Item Diff-L")
self.assertEqual(variant.item_name, "Test Variant Friendly Name-L")
# even a manually customized item_name (unrelated to the auto-generated pattern) must be
# rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep.
frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name")
attribute = frappe.get_doc("Item Attribute", "Test Size")
for row in attribute.item_attribute_values:
if row.attribute_value == "Large":
row.abbr = "LRG"
break
def restore_test_size_abbr():
doc = frappe.get_doc("Item Attribute", "Test Size")
for row in doc.item_attribute_values:
if row.attribute_value == "Large":
row.abbr = "L"
break
frappe.flags.attribute_values = None
doc.save()
self.addCleanup(restore_test_size_abbr)
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1))
frappe.flags.attribute_values = None
attribute.save()
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L"))
self.assertEqual(
frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"),
"Test Variant Friendly Name-LRG",
)
def test_make_item_variant(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
@@ -966,6 +1060,47 @@ class TestItem(FrappeTestCase):
self.assertRaises(frappe.ValidationError, item_doc.save)
def test_cannot_unset_serialized_while_bundle_exists(self):
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
make_serial_batch_bundle,
)
item = make_item(
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
).name
serial_no = f"{item}-SN-01"
frappe.get_doc(
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
).insert()
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
bundle = make_serial_batch_bundle(
{
"item_code": item,
"warehouse": "_Test Warehouse - _TC",
"company": "_Test Company",
"qty": 1,
"rate": 100,
"voucher_type": "Stock Entry",
"serial_nos": [serial_no],
"type_of_transaction": "Inward",
"do_not_submit": True,
"ignore_sabb_validation": True,
}
)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
self.assertRaises(frappe.ValidationError, doc.save)
# Once the bundle is removed, the item can be made non-serialized.
frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
doc.save()
self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
def set_item_variant_settings(fields):
doc = frappe.get_doc("Item Variant Settings")

View File

@@ -10,6 +10,7 @@ from frappe.utils import flt
from erpnext.controllers.item_variant import (
InvalidItemAttributeValueError,
update_variant_attribute_values,
update_variant_item_codes_for_abbr_renames,
validate_is_incremental,
validate_item_attribute_value,
)
@@ -49,6 +50,7 @@ class ItemAttribute(Document):
def on_update(self):
update_variant_attribute_values(self)
update_variant_item_codes_for_abbr_renames(self)
self.validate_exising_items()
self.set_enabled_disabled_in_items()

View File

@@ -297,7 +297,8 @@ frappe.ui.form.on("Pick List", {
items_table_name: "locations",
qty_field: "picked_qty",
max_qty_field: "qty",
dont_allow_new_row: true,
demand_ref_fields: ["sales_order_item", "material_request_item", "product_bundle_item"],
dont_allow_new_row: !frm.doc.pick_manually,
prompt_qty: frm.doc.prompt_qty,
serial_no_field: "serial_no",
};

View File

@@ -184,7 +184,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "Draft\nOpen\nPartly Delivered\nCompleted\nCancelled",
"options": "Draft\nOpen\nPartly Delivered\nPartially Transferred\nCompleted\nCancelled",
"print_hide": 1,
"read_only": 1,
"report_hide": 1,
@@ -246,7 +246,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2025-10-03 18:36:52.282355",
"modified": "2026-07-10 11:39:13.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",

View File

@@ -71,7 +71,9 @@ class PickList(TransactionBase):
purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"]
scan_barcode: DF.Data | None
scan_mode: DF.Check
status: DF.Literal["Draft", "Open", "Partly Delivered", "Completed", "Cancelled"]
status: DF.Literal[
"Draft", "Open", "Partly Delivered", "Partially Transferred", "Completed", "Cancelled"
]
work_order: DF.Link | None
# end: auto-generated types
@@ -398,6 +400,34 @@ class PickList(TransactionBase):
return stock_entry_exists(self.name)
def get_transfer_status(self):
"""Return the pick list's transfer progress based on how much of the picked qty has been
moved into submitted Stock Entries (tracked on Pick List Item.transferred_qty).
Only applies to purposes that move stock via Stock Entry; the Delivery purpose is tracked
via delivery_status instead. Returns "Completed", "Partially Transferred" or None."""
if self.purpose == "Delivery":
return None
total_picked = sum(flt(row.picked_qty) for row in self.locations)
if not total_picked:
return None
total_transferred = sum(flt(row.transferred_qty) for row in self.locations)
if total_transferred <= 0:
return None
if total_transferred >= total_picked:
return "Completed"
return "Partially Transferred"
def is_fully_transferred(self):
return self.get_transfer_status() == "Completed"
def is_partially_transferred(self):
return self.get_transfer_status() == "Partially Transferred"
def update_reference_qty(self):
packed_items = []
so_items = []
@@ -1415,6 +1445,9 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
if location.sales_order != sales_order or location.product_bundle_item:
continue
if flt(location.picked_qty) - flt(location.delivered_qty) <= 0:
continue
if location.sales_order_item:
sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
else:
@@ -1470,13 +1503,10 @@ def add_product_bundles_to_delivery_note(
@frappe.whitelist()
def create_stock_entry(pick_list):
pick_list = frappe.get_doc(json.loads(pick_list))
def create_stock_entry(pick_list: str | dict):
pick_list = frappe.get_doc(frappe.parse_json(pick_list))
validate_item_locations(pick_list)
if stock_entry_exists(pick_list.get("name")):
return frappe.msgprint(_("Stock Entry has been already created against this Pick List"))
stock_entry = frappe.new_doc("Stock Entry")
stock_entry.pick_list = pick_list.get("name")
stock_entry.purpose = pick_list.get("purpose")
@@ -1490,6 +1520,9 @@ def create_stock_entry(pick_list):
else:
stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry)
if not stock_entry.get("items"):
return frappe.msgprint(_("All picked items have already been transferred against this Pick List"))
stock_entry.set_missing_values()
return stock_entry.as_dict()
@@ -1591,6 +1624,8 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
stock_entry.project = work_order.project
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
item = frappe._dict()
update_common_item_properties(item, location)
item.t_warehouse = wip_warehouse
@@ -1602,6 +1637,8 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
def update_stock_entry_based_on_material_request(pick_list, stock_entry):
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
target_warehouse = None
if location.material_request_item:
target_warehouse = frappe.get_value(
@@ -1617,6 +1654,8 @@ def update_stock_entry_based_on_material_request(pick_list, stock_entry):
def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
item = frappe._dict()
update_common_item_properties(item, location)
@@ -1625,11 +1664,18 @@ def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
return stock_entry
def get_pending_transfer_stock_qty(location):
"""Stock qty of this pick list row still to be moved into a Stock Entry."""
return flt(location.picked_qty) - flt(location.transferred_qty)
def update_common_item_properties(item, location):
pending_stock_qty = get_pending_transfer_stock_qty(location)
item.item_code = location.item_code
item.item_name = location.item_name
item.s_warehouse = location.warehouse
item.transfer_qty = location.picked_qty
item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty"))
item.transfer_qty = pending_stock_qty
item.qty = flt(pending_stock_qty / (location.conversion_factor or 1), location.precision("qty"))
item.uom = location.uom
item.conversion_factor = location.conversion_factor
item.stock_uom = location.stock_uom
@@ -1637,6 +1683,7 @@ def update_common_item_properties(item, location):
item.serial_no = location.serial_no
item.batch_no = location.batch_no
item.material_request_item = location.material_request_item
item.pick_list_item = location.name
def get_rejected_warehouses():

View File

@@ -7,6 +7,7 @@ frappe.listview_settings["Pick List"] = {
Draft: "red",
Open: "orange",
"Partly Delivered": "orange",
"Partially Transferred": "yellow",
Completed: "green",
Cancelled: "red",
};

View File

@@ -10,7 +10,11 @@ from erpnext.selling.doctype.sales_order.sales_order import create_pick_list
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle
from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note, create_dn_for_pick_lists
from erpnext.stock.doctype.pick_list.pick_list import (
create_delivery_note,
create_dn_for_pick_lists,
create_stock_entry,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
@@ -1016,6 +1020,103 @@ class TestPickList(FrappeTestCase):
pl.reload()
self.assertEqual(pl.status, "Cancelled")
def test_pick_list_partial_transfer_status(self):
"""Partial Stock Entries from a Pick List should track transferred_qty and drive the
Partially Transferred / Completed status, and allow further transfers for the remainder."""
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
item = make_item(properties={"is_stock_item": 1}).name
source_warehouse = "_Test Warehouse - _TC"
target_warehouse = create_warehouse("_Test Transfer Target Warehouse")
make_stock_entry(item=item, to_warehouse=source_warehouse, qty=10)
pick_list = frappe.get_doc(
{
"doctype": "Pick List",
"company": "_Test Company",
"purpose": "Material Transfer",
"pick_manually": 1,
"locations": [
{
"item_code": item,
"qty": 10,
"stock_qty": 10,
"conversion_factor": 1,
"warehouse": source_warehouse,
"picked_qty": 10,
}
],
}
)
pick_list.submit()
self.assertEqual(pick_list.status, "Open")
# Transfer 4 of the 10 picked units.
se1 = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(se1.items[0].qty, 10)
se1.items[0].qty = 4
se1.items[0].t_warehouse = target_warehouse
se1.submit()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
# The next Stock Entry should only offer the remaining 6 units.
se2 = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(se2.items[0].qty, 6)
se2.items[0].t_warehouse = target_warehouse
se2.submit()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 10)
self.assertEqual(pick_list.status, "Completed")
# Cancelling the last entry rolls transferred_qty and status back.
se2.cancel()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
def test_create_second_delivery_note_with_fully_delivered_location(self):
# When one pick list item is fully delivered by the first Delivery Note
# and another item is still pending, creating a second Delivery Note from
# the Pick List must not create a zero-qty row for the delivered item.
warehouse = "_Test Warehouse - _TC"
item_a = make_item(properties={"is_stock_item": 1}).name
item_b = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item=item_a, to_warehouse=warehouse, qty=20)
make_stock_entry(item=item_b, to_warehouse=warehouse, qty=20)
so = make_sales_order(
item_list=[
{"item_code": item_a, "warehouse": warehouse, "qty": 10, "rate": 100},
{"item_code": item_b, "warehouse": warehouse, "qty": 5, "rate": 100},
]
)
pl = create_pick_list(so.name)
pl.save().submit()
# First Delivery Note: fully deliver item_a, drop item_b.
dn1 = create_delivery_note(pl.name)
for row in list(dn1.items):
if row.item_code == item_b:
dn1.remove(row)
dn1.save().submit()
pl.reload()
delivered = {loc.item_code: loc.delivered_qty for loc in pl.locations}
self.assertEqual(delivered[item_a], 10)
self.assertEqual(delivered[item_b], 0)
# Second Delivery Note for the remaining item must succeed and must not
# include a zero-qty row for the already delivered item_a.
dn2 = create_delivery_note(pl.name)
self.assertEqual(len(dn2.items), 1)
self.assertEqual(dn2.items[0].item_code, item_b)
self.assertEqual(dn2.items[0].qty, 5)
def test_pick_list_validation(self):
warehouse = "_Test Warehouse - _TC"
item = make_item("Test Non Serialized Pick List Item", properties={"is_stock_item": 1}).name

View File

@@ -22,6 +22,7 @@
"conversion_factor",
"stock_uom",
"delivered_qty",
"transferred_qty",
"available_quantity_section",
"actual_qty",
"column_break_kyek",
@@ -254,6 +255,16 @@
"read_only": 1,
"report_hide": 1
},
{
"default": "0",
"fieldname": "transferred_qty",
"fieldtype": "Float",
"label": "Transferred Qty (in Stock UOM)",
"no_copy": 1,
"print_hide": 1,
"read_only": 1,
"report_hide": 1
},
{
"fieldname": "available_quantity_section",
"fieldtype": "Section Break",
@@ -284,7 +295,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-03-17 16:25:10.358013",
"modified": "2026-07-06 18:17:18.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",

View File

@@ -39,6 +39,7 @@ class PickListItem(Document):
stock_qty: DF.Float
stock_reserved_qty: DF.Float
stock_uom: DF.Link | None
transferred_qty: DF.Float
uom: DF.Link | None
use_serial_batch_fields: DF.Check
warehouse: DF.Link | None

View File

@@ -1662,6 +1662,93 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(query[0].value, 0)
def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self):
"""Internal-transfer PR's inward SLE must use DN.incoming_rate even when
PR.item.valuation_rate was wrong at submit, so divisional_loss does not
leak to COGS."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.stock_ledger import update_entries_after
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Drift From", company=company)
transit_warehouse = create_warehouse("_Test Drift Transit", company=company)
to_warehouse = create_warehouse("_Test Drift Receiver", company=company)
item_doc = create_item("Test Internal Drift Item")
make_purchase_receipt(
item_code=item_doc.name,
company=company,
posting_date=add_days(today(), -1),
warehouse=from_warehouse,
qty=10,
rate=100,
)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=1,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
)
self.assertEqual(flt(dn.items[0].incoming_rate), 100.0)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.submit()
inward_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"warehouse": to_warehouse,
"is_cancelled": 0,
},
["name", "item_code", "warehouse", "posting_date", "posting_time", "creation", "incoming_rate"],
as_dict=True,
)
self.assertEqual(flt(inward_sle.incoming_rate), 100.0)
frappe.db.set_value(
"Purchase Receipt Item",
pr.items[0].name,
{"sales_incoming_rate": 0, "valuation_rate": 80},
)
frappe.db.set_value(
"Stock Ledger Entry",
inward_sle.name,
{"incoming_rate": 80, "stock_value_difference": 80},
)
update_entries_after(
{
"item_code": inward_sle.item_code,
"warehouse": inward_sle.warehouse,
"posting_date": inward_sle.posting_date,
"posting_time": inward_sle.posting_time,
"sle_id": inward_sle.name,
"creation": inward_sle.creation,
}
)
refreshed = frappe.db.get_value(
"Stock Ledger Entry",
inward_sle.name,
["incoming_rate", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(flt(refreshed.incoming_rate), 100.0)
self.assertEqual(flt(refreshed.stock_value_difference), 100.0)
def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_invoice(
self,
):

View File

@@ -26,6 +26,7 @@ from frappe.utils import (
)
from frappe.utils.csvutils import build_csv_response
from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem
from erpnext.stock.serial_batch_bundle import (
BatchNoValuation,
SerialNoValuation,
@@ -2092,9 +2093,14 @@ def get_reference_serial_and_batch_bundle(child_row):
@frappe.whitelist()
def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=False) -> object:
if isinstance(child_row, str):
child_row = frappe._dict(parse_json(child_row))
def add_serial_batch_ledgers(
entries: list | str,
child_row: PurchaseReceiptItem | dict | str,
doc: Document | dict | str,
warehouse: str | None = None,
do_not_save: bool = False,
):
child_row = parse_json(child_row)
if isinstance(entries, str):
entries = parse_json(entries)
@@ -2126,7 +2132,9 @@ def create_serial_batch_no_ledgers(
if parent_doc.get("doctype") == "Stock Entry":
warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse
posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time"))
posting_datetime = combine_datetime(
parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime()
)
doc = frappe.get_doc(
{
@@ -2243,7 +2251,9 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh
)
doc.voucher_detail_no = child_row.name
doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time"))
doc.posting_datetime = combine_datetime(
parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime()
)
doc.warehouse = warehouse or doc.warehouse
doc.set("entries", [])
@@ -2467,22 +2477,24 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos):
def get_bundle_wise_serial_nos(data, kwargs):
bundle_wise_serial_nos = defaultdict(list)
bundles = [d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle]
bundles = list({d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle})
if not bundles:
return bundle_wise_serial_nos
filters = {"parent": ("in", bundles), "docstatus": 1, "serial_no": ("is", "set")}
if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"):
filters["serial_no"] = ("in", kwargs.get("serial_nos"))
bundle_data = frappe.get_all(
"Serial and Batch Entry",
fields=["serial_no", "parent"],
filters=filters,
sabe = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(sabe)
.select(sabe.serial_no, sabe.parent)
.where(sabe.parent.isin(bundles))
.where(sabe.docstatus == 1)
.where(sabe.serial_no.isnotnull())
.where(sabe.serial_no != "")
)
for d in bundle_data:
if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"):
query = query.where(sabe.serial_no.isin(kwargs.get("serial_nos")))
for d in query.run(as_dict=True):
if d.parent:
bundle_wise_serial_nos[d.parent].append(d.serial_no)

View File

@@ -11,8 +11,12 @@ from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
add_serial_batch_ledgers,
combine_datetime,
get_available_batches_qty,
get_qty_based_available_batches,
get_type_of_transaction,
make_batch_nos,
make_serial_nos,
parse_serial_nos,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -1383,3 +1387,128 @@ def make_serial_batch_bundle(kwargs):
return sb.make_serial_and_batch_bundle()
return sb
class TestSerialandBatchBundleLogic(FrappeTestCase):
"""Pure helpers and in-memory document validations, covering branches the
integration suite doesn't reach (no stock-ledger / serial / batch fixtures)."""
def test_parse_serial_nos_splits_and_trims(self):
self.assertEqual(parse_serial_nos("SN1\nSN2"), ["SN1", "SN2"])
self.assertEqual(parse_serial_nos("SN1, SN2 , SN3"), ["SN1", "SN2", "SN3"])
# blanks are dropped and an existing list is returned unchanged
self.assertEqual(parse_serial_nos("SN1,,\n , SN2"), ["SN1", "SN2"])
self.assertEqual(parse_serial_nos(["SN1", "SN2"]), ["SN1", "SN2"])
def test_get_qty_based_available_batches_allocates_across_batches(self):
batches = [
frappe._dict(batch_no="B1", qty=10, warehouse="W"),
frappe._dict(batch_no="B2", qty=5, warehouse="W"),
]
# 12 consumes B1 fully then 2 from B2
result = get_qty_based_available_batches(batches, 12)
self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 10), ("B2", 2)])
# 8 is satisfied by B1 alone; B2 is not touched
result = get_qty_based_available_batches(batches, 8)
self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 8)])
def test_get_available_batches_qty_aggregates_by_batch(self):
batches = [
frappe._dict(batch_no="B1", qty=10),
frappe._dict(batch_no="B2", qty=5),
frappe._dict(batch_no="B1", qty=3),
]
agg = get_available_batches_qty(batches)
self.assertEqual(agg["B1"], 13)
self.assertEqual(agg["B2"], 5)
def test_get_type_of_transaction_derives_direction(self):
def se(**kw):
return get_type_of_transaction(frappe._dict(doctype="Stock Entry"), frappe._dict(**kw))
self.assertEqual(se(s_warehouse="W"), "Outward") # issuing from a source warehouse
self.assertEqual(se(), "Inward") # only a target warehouse
self.assertEqual(
get_type_of_transaction(frappe._dict(doctype="Purchase Receipt"), frappe._dict()), "Inward"
)
self.assertEqual(
get_type_of_transaction(frappe._dict(doctype="Stock Reconciliation"), frappe._dict()), "Inward"
)
# a purchase return reverses the direction to Outward
self.assertEqual(
get_type_of_transaction(frappe._dict(doctype="Purchase Receipt", is_return=1), frappe._dict()),
"Outward",
)
def test_duplicate_serial_no_in_entries_is_rejected(self):
doc = frappe.new_doc("Serial and Batch Bundle")
doc.append("entries", {"serial_no": "SN1"})
doc.append("entries", {"serial_no": "SN1"})
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no)
def test_duplicate_batch_no_in_entries_is_rejected(self):
doc = frappe.new_doc("Serial and Batch Bundle")
doc.append("entries", {"batch_no": "B1"})
doc.append("entries", {"batch_no": "B1"})
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no)
def test_voucher_no_is_mandatory(self):
doc = frappe.new_doc("Serial and Batch Bundle")
self.assertRaises(frappe.ValidationError, doc.validate_serial_and_batch_data)
def test_calculate_total_qty_normalizes_and_signs(self):
inward = frappe.new_doc("Serial and Batch Bundle")
inward.type_of_transaction = "Inward"
inward.append("entries", {"qty": 5})
inward.append("entries", {"qty": 3})
inward.calculate_total_qty(save=False)
self.assertEqual(inward.total_qty, 8)
# Outward flips the sign
outward = frappe.new_doc("Serial and Batch Bundle")
outward.type_of_transaction = "Outward"
outward.append("entries", {"qty": 5})
outward.calculate_total_qty(save=False)
self.assertEqual(outward.total_qty, -5)
# a serialized bundle normalizes each row qty to 1
serialized = frappe.new_doc("Serial and Batch Bundle")
serialized.has_serial_no = 1
serialized.type_of_transaction = "Inward"
serialized.append("entries", {"qty": 5})
serialized.calculate_total_qty(save=False)
self.assertEqual(serialized.total_qty, 1)
def test_get_bundle_wise_serial_nos(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_bundle_wise_serial_nos,
)
item_code = make_item(properties={"has_serial_no": 1, "serial_no_series": "TEST-BWSN-.#####"}).name
bundles = []
for _ in range(2):
se = make_stock_entry(
item_code=item_code,
target="_Test Warehouse - _TC",
qty=3,
rate=100,
)
bundles.append(se.items[0].serial_and_batch_bundle)
data = [frappe._dict(serial_and_batch_bundle=bundle) for bundle in bundles]
self.assertEqual(get_bundle_wise_serial_nos([], {}), {})
bundle_wise_serial_nos = get_bundle_wise_serial_nos(data, {})
for bundle in bundles:
self.assertEqual(sorted(bundle_wise_serial_nos[bundle]), get_serial_nos_from_bundle(bundle))
# check_serial_nos must restrict the result to the requested serial nos
serial_no = get_serial_nos_from_bundle(bundles[0])[0]
bundle_wise_serial_nos = get_bundle_wise_serial_nos(
data, {"check_serial_nos": True, "serial_nos": [serial_no]}
)
self.assertNotIn(bundles[1], bundle_wise_serial_nos)
self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no])

View File

@@ -163,6 +163,15 @@ class StockEntry(StockController):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.status_updater = [
{
"source_dt": "Stock Entry Detail",
"target_dt": "Pick List Item",
"join_field": "pick_list_item",
"target_field": "transferred_qty",
"source_field": "transfer_qty",
}
]
if self.purchase_order:
self.subcontract_data = frappe._dict(
{
@@ -519,6 +528,7 @@ class StockEntry(StockController):
self.validate_closed_subcontracting_order()
self.update_subcontract_order_supplied_items()
self.update_subcontracting_order_status()
self.update_pick_list_status()
if self.work_order and self.purpose == "Material Consumption for Manufacture":
self.validate_work_order_status()
@@ -3544,6 +3554,9 @@ class StockEntry(StockController):
def update_pick_list_status(self):
from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status
if self.pick_list:
self.update_qty()
update_pick_list_status(self.pick_list)
def set_missing_values(self):

View File

@@ -68,6 +68,7 @@
"col_break6",
"material_request",
"material_request_item",
"pick_list_item",
"original_item",
"reference_section",
"against_stock_entry",
@@ -337,7 +338,6 @@
"print_hide": 1
},
{
"default": ":Company",
"depends_on": "eval:cint(erpnext.is_perpetual_inventory_enabled(parent.company))",
"fieldname": "cost_center",
"fieldtype": "Link",
@@ -416,6 +416,16 @@
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "pick_list_item",
"fieldtype": "Link",
"hidden": 1,
"label": "Pick List Item",
"no_copy": 1,
"options": "Pick List Item",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "original_item",
"fieldtype": "Link",
@@ -616,7 +626,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-04-27 11:40:38.294196",
"modified": "2026-07-06 18:17:18.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",

View File

@@ -43,6 +43,7 @@ class StockEntryDetail(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
pick_list_item: DF.Link | None
po_detail: DF.Data | None
project: DF.Link | None
putaway_rule: DF.Link | None

View File

@@ -12,6 +12,7 @@ from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import add_days, add_months, cint, cstr, flt, get_link_to_form, getdate, parse_json
import erpnext
from erpnext import get_company_currency
from erpnext.accounts.doctype.pricing_rule.pricing_rule import (
get_pricing_rule_for_item,
@@ -176,10 +177,9 @@ def update_bin_details(args, out, doc):
out.update(get_bin_details(args.item_code, args.get("from_warehouse")))
elif out.get("warehouse"):
company = args.company if (doc and doc.get("doctype") == "Purchase Order") else None
# calculate company_total_stock only for po
bin_details = get_bin_details(args.item_code, out.warehouse, company, include_child_warehouses=True)
bin_details = get_bin_details(
args.item_code, out.warehouse, args.company, include_child_warehouses=True
)
out.update(bin_details)
@@ -410,12 +410,26 @@ def get_basic_details(args, item, overwrite_warehouse=True):
expense_account = None
if args.get("doctype") == "Purchase Invoice" and item.is_fixed_asset:
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
if item.is_fixed_asset:
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
expense_account = get_asset_category_account(
fieldname="fixed_asset_account", item=args.item_code, company=args.company
)
if is_cwip_accounting_enabled(item.asset_category):
expense_account = get_asset_account(
"capital_work_in_progress_account",
asset_category=item.asset_category,
company=args.company,
)
elif args.get("doctype") in (
"Purchase Invoice",
"Purchase Receipt",
"Purchase Order",
"Material Request",
):
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
expense_account = get_asset_category_account(
fieldname="fixed_asset_account", item=args.item_code, company=args.company
)
# Set the UOM to the Default Sales UOM or Default Purchase UOM if configured in the Item Master
if not args.get("uom"):
@@ -518,10 +532,21 @@ def get_basic_details(args, item, overwrite_warehouse=True):
args.name, args.conversion_rate, item.name, out.conversion_factor
)
expense_account_field = "default_expense_account"
if (
item.is_stock_item
and erpnext.is_perpetual_inventory_enabled(args.company)
and (
args.doctype == "Purchase Receipt"
or (args.doctype == "Purchase Invoice" and args.get("update_stock"))
)
):
expense_account_field = "stock_received_but_not_billed"
# if default specified in item is for another company, fetch from company
for d in [
["Account", "income_account", "default_income_account"],
["Account", "expense_account", "default_expense_account"],
["Account", "expense_account", expense_account_field],
["Cost Center", "cost_center", "cost_center"],
["Warehouse", "warehouse", ""],
]:
@@ -1492,6 +1517,11 @@ def apply_price_list(args, as_doc=False, doc=None):
def apply_price_list_on_item(args, doc=None):
item_doc = frappe.db.get_value("Item", args.item_code, ["name", "variant_of"], as_dict=1)
item_details = get_price_list_rate(args, item_doc)
args.conversion_factor = flt(args.conversion_factor) or get_conversion_factor(
args.item_code, args.uom
).get("conversion_factor", 1)
args.stock_qty = flt(args.qty) * flt(args.conversion_factor)
item_details.update(get_pricing_rule_for_item(args, doc=doc))
return item_details

View File

@@ -186,6 +186,10 @@ def get_item_warehouse_projected_qty(items_to_consider):
item_warehouse_projected_qty = {}
items_to_consider = list(items_to_consider.keys())
warehouse_parent_map = frappe._dict(
frappe.get_all("Warehouse", fields=["name", "parent_warehouse"], as_list=True)
)
for item_code, warehouse, projected_qty in frappe.db.sql(
"""select item_code, warehouse, projected_qty
from tabBin where item_code in ({})
@@ -200,16 +204,14 @@ def get_item_warehouse_projected_qty(items_to_consider):
if warehouse not in item_warehouse_projected_qty.get(item_code):
item_warehouse_projected_qty[item_code][warehouse] = flt(projected_qty)
warehouse_doc = frappe.get_doc("Warehouse", warehouse)
parent_warehouse = warehouse_parent_map.get(warehouse)
while warehouse_doc.parent_warehouse:
if not item_warehouse_projected_qty.get(item_code, {}).get(warehouse_doc.parent_warehouse):
item_warehouse_projected_qty.setdefault(item_code, {})[warehouse_doc.parent_warehouse] = flt(
projected_qty
)
while parent_warehouse:
if not item_warehouse_projected_qty.get(item_code, {}).get(parent_warehouse):
item_warehouse_projected_qty.setdefault(item_code, {})[parent_warehouse] = flt(projected_qty)
else:
item_warehouse_projected_qty[item_code][warehouse_doc.parent_warehouse] += flt(projected_qty)
warehouse_doc = frappe.get_doc("Warehouse", warehouse_doc.parent_warehouse)
item_warehouse_projected_qty[item_code][parent_warehouse] += flt(projected_qty)
parent_warehouse = warehouse_parent_map.get(parent_warehouse)
return item_warehouse_projected_qty

View File

@@ -287,6 +287,7 @@ class FIFOSlots:
self.serial_no_details = {}
self.batch_no_details = {}
self.batchwise_valuation_by_batch = {}
self.valuation_method_by_item = {}
self.filters = filters
self.sle = sle
@@ -307,9 +308,10 @@ class FIFOSlots:
self.prepare_stock_reco_voucher_wise_count()
if stock_ledger_entries is None:
# nested queries invalidate the streaming cursor below,
# so batchwise valuation flags must be resolved beforehand
# streaming path: nested queries invalidate the streaming cursor below,
# so batchwise valuation flags and item valuation methods must be resolved beforehand
self._prefetch_batchwise_valuations()
self._prefetch_valuation_methods()
with frappe.db.unbuffered_cursor():
if stock_ledger_entries is None:
@@ -321,12 +323,28 @@ class FIFOSlots:
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
del stock_ledger_entries
self._recompute_moving_average_slots()
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
self.item_details = self._aggregate_details_by_item(self.item_details)
return self.item_details
def _recompute_moving_average_slots(self) -> None:
for item_dict in self.item_details.values():
if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"):
continue
details = item_dict["details"]
if self._get_item_valuation_method(details.name) != "Moving Average":
continue
rate = flt(details.valuation_rate)
for slot in item_dict["fifo_queue"]:
if is_qty_slot(slot):
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
if stock_ledger_entries is not None:
return frappe._dict({}), frappe._dict({})
@@ -347,7 +365,10 @@ class FIFOSlots:
if row.actual_qty > 0:
self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
else:
self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
from_end = self._get_item_valuation_method(row.name) == "LIFO"
self._compute_outgoing_stock(
row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end
)
self._update_balances(row, key)
self._trim_serial_fifo_queue(row, key, fifo_queue)
@@ -460,6 +481,43 @@ class FIFOSlots:
for batch_no, use_batchwise_valuation in query.run():
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
def _get_item_valuation_method(self, item_code: str) -> str:
from erpnext.stock.utils import get_valuation_method
if item_code not in self.valuation_method_by_item:
# only reachable when stock ledger entries are passed in directly;
# the streaming path prefetches all methods before iteration
self.valuation_method_by_item[item_code] = get_valuation_method(item_code)
return self.valuation_method_by_item[item_code]
def _prefetch_valuation_methods(self) -> None:
from erpnext.stock.utils import get_valuation_method
company = self.filters.get("company")
sle = frappe.qb.DocType("Stock Ledger Entry")
item = frappe.qb.DocType("Item")
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
query = (
frappe.qb.from_(sle)
.inner_join(item)
.on(sle.item_code == item.name)
.select(item.name, item.valuation_method)
.distinct()
.where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1))
)
query = self._apply_filter(query, sle, "item_code")
# items with no item-level method share the company/settings default; resolve it once
default_method = None
for item_code, valuation_method in query.run():
if not valuation_method:
if default_method is None:
default_method = get_valuation_method(item_code)
valuation_method = default_method
self.valuation_method_by_item[item_code] = valuation_method
def _init_key_stores(self, row: dict) -> tuple:
"Initialise keys and FIFO Queue."
@@ -492,7 +550,7 @@ class FIFOSlots:
self._add_serial_fifo_slots(row, fifo_queue, serial_nos)
elif batch_nos and row.get("has_batch_no"):
self._add_batch_fifo_slots(row, fifo_queue, batch_nos)
elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
self._add_to_negative_fifo_head(row, fifo_queue)
else:
fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)])
@@ -576,7 +634,13 @@ class FIFOSlots:
fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference)
def _compute_outgoing_stock(
self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list
self,
row: dict,
fifo_queue: list,
transfer_key: tuple,
serial_nos: list,
batch_nos: list,
from_end: bool = False,
):
"Update FIFO Queue on outward stock."
if serial_nos:
@@ -584,7 +648,7 @@ class FIFOSlots:
elif batch_nos:
self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos)
else:
self._consume_fifo_slots(row, fifo_queue, transfer_key)
self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end)
def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None:
fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos]
@@ -661,19 +725,23 @@ class FIFOSlots:
)
self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference])
def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None:
def _consume_fifo_slots(
self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False
) -> None:
# LIFO consumes the most recent inward first, so pop from the tail instead of the head.
index = -1 if from_end else 0
qty_to_pop = abs(row.actual_qty)
stock_value = abs(row.stock_value_difference)
while qty_to_pop:
slot = fifo_queue[0] if fifo_queue else [0, None, 0]
slot = fifo_queue[index] if fifo_queue else [0, None, 0]
slot_qty = flt(slot[FIFO_QTY_INDEX])
slot_value = flt(slot[FIFO_VALUE_INDEX])
if 0 < slot_qty <= qty_to_pop:
qty_to_pop -= slot_qty
stock_value -= slot_value
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
self.transferred_item_details[transfer_key].append(fifo_queue.pop(index))
elif not fifo_queue:
fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
self.transferred_item_details[transfer_key].append(

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from frappe.tests.utils import FrappeTestCase
@@ -67,6 +69,131 @@ class TestStockAgeing(FrappeTestCase):
data = format_report_data(self.filters, slots, self.filters["to_date"])
self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30
def test_moving_average_value_ties_to_stock_balance(self):
"""For Moving Average items the queue value is re-derived as qty * rate so the
report's stock value ties to Stock Balance, instead of stranding a residual
from FIFO-by-qty consumption vs blended outgoing value."""
sle = [
frappe._dict(
name="MA Item",
actual_qty=10,
qty_after_transaction=10,
stock_value_difference=1000,
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="MA Item",
actual_qty=10,
qty_after_transaction=20,
stock_value_difference=2000,
valuation_rate=150,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="MA Item",
actual_qty=(-10),
qty_after_transaction=10,
stock_value_difference=(-1500),
valuation_rate=150,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="MA Item",
actual_qty=(-5),
qty_after_transaction=5,
stock_value_difference=(-750),
valuation_rate=150,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False,
serial_no=None,
),
]
with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"):
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["MA Item"]["fifo_queue"]
total_value = sum(slot[2] for slot in queue)
# Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150
self.assertEqual(total_value, 750.0)
def test_lifo_consumes_newest_first(self):
"""LIFO items consume the most recent inward first, so the oldest lot stays on
hand. The remaining queue, stock value and average age must reflect the older
stock, unlike the default FIFO which retains the newest lots."""
sle = [
frappe._dict(
name="LIFO Item",
actual_qty=30,
qty_after_transaction=30,
stock_value_difference=30,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="LIFO Item",
actual_qty=20,
qty_after_transaction=50,
stock_value_difference=20,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="LIFO Item",
actual_qty=(-10),
qty_after_transaction=40,
stock_value_difference=(-10),
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False,
serial_no=None,
),
]
with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"):
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["LIFO Item"]["fifo_queue"]
# newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10
self.assertEqual(queue[0][0], 30.0)
self.assertEqual(queue[-1][0], 10.0)
self.assertEqual(sum(slot[0] for slot in queue), 40.0)
self.assertEqual(sum(slot[2] for slot in queue), 40.0)
# average age skews older than the FIFO result (8.5) because the old lot is retained
self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75)
def test_insufficient_balance(self):
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
sle = [
@@ -1438,6 +1565,47 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(item_result["total_qty"], -4.0)
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
def test_untagged_receipt_with_negative_batch_head(self):
"""An incoming SLE without batch details must not treat a negative
batch slot at the queue head as a qty slot (TypeError: str += float)."""
sle = [
frappe._dict(
name="Enclosure Item",
actual_qty=-10,
qty_after_transaction=-10,
stock_value_difference=-100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no="QI-06448",
),
frappe._dict(
name="Enclosure Item",
actual_qty=45,
qty_after_transaction=35,
stock_value_difference=1051.65,
warehouse="WH 1",
posting_date="2021-12-05",
voucher_type="Purchase Receipt",
voucher_no="002",
has_serial_no=False,
serial_no=None,
batch_no=None,
serial_and_batch_bundle="SABB-00001294",
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Enclosure Item"]["fifo_queue"]
self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0)
self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0])
self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65])
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
from frappe.utils import add_days, getdate, nowdate

View File

@@ -171,14 +171,20 @@ def get_columns(filters):
@frappe.whitelist()
def create_reposting_entries(rows, company):
def create_reposting_entries(rows: str | list, company: str):
if isinstance(rows, str):
rows = parse_json(rows)
entries = []
item_wh = frappe._dict()
vouchers = [row.get("voucher_no") for row in rows]
vouchers = [
row.get("voucher_no")
for row in rows
if row.get("voucher_type") not in ["Purchase Receipt", "Purchase Invoice"]
]
repost_based_on_transaction(rows, company, entries)
sles = get_stock_ledgers(vouchers)
for sle in sles:
key = (sle.item_code, sle.warehouse)
@@ -211,3 +217,39 @@ def create_reposting_entries(rows, company):
if entries:
entries = ", ".join(entries)
frappe.msgprint(_("Reposting entries created: {0}").format(entries))
def repost_based_on_transaction(rows, company=None, entries=None):
if entries is None:
entries = []
duplicate_vouchers = set()
for row in rows:
if (
row.get("voucher_type") == "Purchase Invoice"
and frappe.get_cached_value("Purchase Invoice", row.get("voucher_no"), "update_stock") == 0
):
continue
if row.get("voucher_type") in ["Purchase Receipt", "Purchase Invoice"]:
voucher_key = (row.get("voucher_type"), row.get("voucher_no"))
if voucher_key in duplicate_vouchers:
continue
duplicate_vouchers.add(voucher_key)
doc = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Transaction",
"status": "Queued",
"voucher_type": row.get("voucher_type"),
"voucher_no": row.get("voucher_no"),
"posting_date": row.get("posting_date"),
"posting_time": row.get("posting_time"),
"company": company,
"allow_nagative_stock": 1,
"recalculate_valuation_rate": 1,
}
).submit()
entries.append(get_link_to_form("Repost Item Valuation", doc.name))

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