Compare commits

..

154 Commits

Author SHA1 Message Date
Frappe PR Bot
1a0bf0bf6c chore(release): Bumped to Version 15.121.0
# [15.121.0](https://github.com/frappe/erpnext/compare/v15.120.0...v15.121.0) (2026-09-02)

### Bug Fixes

* **accounts:** set pos profile on invoices respecting user permissions (backport [#58508](https://github.com/frappe/erpnext/issues/58508)) ([#58518](https://github.com/frappe/erpnext/issues/58518)) ([1187fb8](1187fb8e01))
* **accounts:** validate frozen accounts in period closing voucher ([#58477](https://github.com/frappe/erpnext/issues/58477)) ([045d708](045d7086d1))
* add missing perm checks on various whitelisted methods (backport [#58652](https://github.com/frappe/erpnext/issues/58652)) ([#58653](https://github.com/frappe/erpnext/issues/58653)) ([547d82d](547d82da56))
* add type hints to make_request_for_quotation ([567730e](567730e38e))
* **bank_statement_import:** add missing permission check on multiple whitelisted methods (backport [#58221](https://github.com/frappe/erpnext/issues/58221)) ([#58223](https://github.com/frappe/erpnext/issues/58223)) ([fa903f4](fa903f40fd))
* clarify duplicate internal party messages ([#58469](https://github.com/frappe/erpnext/issues/58469)) ([17208e7](17208e7434))
* **company:** throw if linked to demo_company field ([de260bd](de260bd43c))
* **crm:** add missing permission validation on `get_contract_template` (backport [#58621](https://github.com/frappe/erpnext/issues/58621)) ([#58622](https://github.com/frappe/erpnext/issues/58622)) ([d1b615b](d1b615b851))
* **crm:** check write permission in edit_note ([2b2a005](2b2a005682))
* do not map the same row twice in "Get Items From" (backport [#58617](https://github.com/frappe/erpnext/issues/58617)) ([#58618](https://github.com/frappe/erpnext/issues/58618)) ([28175a5](28175a5c9d))
* filter fully ordered items when creating RFQ from Material Request ([08ec311](08ec31174c))
* keep Currency and Price List section open for foreign currency (backport [#58074](https://github.com/frappe/erpnext/issues/58074)) ([#58112](https://github.com/frappe/erpnext/issues/58112)) ([f5ce20f](f5ce20f122))
* keep source rate on re-fetch when maintain same rate is enabled (backport [#57479](https://github.com/frappe/erpnext/issues/57479)) ([#58332](https://github.com/frappe/erpnext/issues/58332)) ([228ab2d](228ab2d97e)), closes [frappe/erpnext#57436](https://github.com/frappe/erpnext/issues/57436)
* **manufacturing:** preserve job card qty in mr transfer (backport [#58482](https://github.com/frappe/erpnext/issues/58482)) ([#58498](https://github.com/frappe/erpnext/issues/58498)) ([099a5a7](099a5a71d9))
* **manufacturing:** use packed row delivery date ([#58569](https://github.com/frappe/erpnext/issues/58569)) ([9c19b02](9c19b024db))
* mark item tax templates as not applicable (backport [#54673](https://github.com/frappe/erpnext/issues/54673)) (backport [#54677](https://github.com/frappe/erpnext/issues/54677)) ([#58462](https://github.com/frappe/erpnext/issues/58462)) ([d62f6e7](d62f6e715f))
* persist redistributed additional costs during stock entry repost (backport [#58433](https://github.com/frappe/erpnext/issues/58433)) ([#58532](https://github.com/frappe/erpnext/issues/58532)) ([c4e7cfa](c4e7cfa6a0))
* **projects:** check read permission on source in create_duplicate_project ([af8c010](af8c010aa8))
* **projects:** ignore cancelled invoices in timesheet portal (backport [#58501](https://github.com/frappe/erpnext/issues/58501)) ([#58503](https://github.com/frappe/erpnext/issues/58503)) ([5a51895](5a518951e4))
* reset hardcoded letter head on Incorrect Serial and Batch Bundle report ([ccdaa92](ccdaa92de8))
* resolve analytics backport conflicts ([605918d](605918d3a2))
* resolve conflicts ([c6717fb](c6717fb758))
* resolve stock entry backport conflicts ([e126c45](e126c4597b))
* round party balances to currency precision in Trial Balance for Party ([b98d193](b98d193b77))
* **selling:** check quotation write permission before marking lost ([#58493](https://github.com/frappe/erpnext/issues/58493)) ([33ebc7d](33ebc7d7e3))
* **selling:** check sales order permission before work order creation ([#58492](https://github.com/frappe/erpnext/issues/58492)) ([0609b06](0609b06564))
* **sms_settings:** add patch to pre-fill roles into SMS Settings Roles Table ([0cef90d](0cef90d079))
* **stock:** keep pick list links when refetching stock entry items ([22377fa](22377fa86d))
* **stock:** load available serial no report (backport [#58558](https://github.com/frappe/erpnext/issues/58558)) ([#58561](https://github.com/frappe/erpnext/issues/58561)) ([2d46bb9](2d46bb962c))
* **stock:** preserve exchange gain loss journals in lcv ([#58505](https://github.com/frappe/erpnext/issues/58505)) ([c6fedf4](c6fedf4aca))
* **stock:** reset bin when a repost finds no stock ledger entries  ([#58434](https://github.com/frappe/erpnext/issues/58434)) ([b00062d](b00062df86)), closes [#58362](https://github.com/frappe/erpnext/issues/58362)
* **stock:** validate serial inventory dimensions ([#58394](https://github.com/frappe/erpnext/issues/58394)) ([bc071d7](bc071d7793))
* sum item and its alternate transferred qty on work order required items ([5851bb3](5851bb3b6e))
* **taxes:** skip tax addition for invoice created from opening invoice tool ([7090c6f](7090c6fdab))
* **timesheet:** scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport [#58267](https://github.com/frappe/erpnext/issues/58267)) ([#58576](https://github.com/frappe/erpnext/issues/58576)) ([c656497](c656497aac))
* translate doctype in map msg (backport [#58515](https://github.com/frappe/erpnext/issues/58515)) ([#58516](https://github.com/frappe/erpnext/issues/58516)) ([d87a4b2](d87a4b2541))
* translate label in party validation (backport [#58624](https://github.com/frappe/erpnext/issues/58624)) ([#58625](https://github.com/frappe/erpnext/issues/58625)) ([307ffcd](307ffcd5c3))
* validate items against source Sales Order in Material Request ([32d44c6](32d44c6e91))
* validate POS Settings invoice and search fields on the server (backport [#58611](https://github.com/frappe/erpnext/issues/58611)) ([#58612](https://github.com/frappe/erpnext/issues/58612)) ([c5bc5f9](c5bc5f9c31))
* Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows ([#58527](https://github.com/frappe/erpnext/issues/58527)) ([7454735](7454735595))

### Features

* **analytics:** filter sales and purchase analytics by entity ([#58402](https://github.com/frappe/erpnext/issues/58402)) ([0dddd00](0dddd00073))
2026-09-02 07:16:53 +00:00
Diptanil Saha
57266ab474 Merge pull request #58646 from frappe/version-15-hotfix
chore: release v15
2026-09-02 12:45:04 +05:30
Shllokkk
865b207340 Merge pull request #58676 from frappe/mergify/bp/version-15-hotfix/pr-58535
fix: filter fully ordered items when creating RFQ from Material Request (backport #58535)
2026-09-02 12:19:57 +05:30
Sudharsanan Ashok
05199aa3bb Merge pull request #58674 from frappe/mergify/bp/version-15-hotfix/pr-58505
fix(stock): preserve exchange gain loss journals in lcv (backport #58505)
2026-09-02 12:13:02 +05:30
Shllokkk
4a4e1908e3 chore: resolve backport conflict 2026-09-02 11:50:21 +05:30
Shllokkk
b439deda88 test: set schedule date on PO before insert
(cherry picked from commit 5af60cf2e3)
2026-09-02 06:16:30 +00:00
Shllokkk
567730e38e fix: add type hints to make_request_for_quotation
(cherry picked from commit 4435f166ea)
2026-09-02 06:16:30 +00:00
Shllokkk
3d563a2e08 test: RFQ from Material Request skips fully ordered items
(cherry picked from commit 5adcb28b3c)

# Conflicts:
#	erpnext/stock/doctype/material_request/test_material_request.py
2026-09-02 06:16:29 +00:00
Shllokkk
08ec31174c fix: filter fully ordered items when creating RFQ from Material Request
(cherry picked from commit 9b1d907b6a)
2026-09-02 06:16:29 +00:00
Pandiyan P
c6fedf4aca fix(stock): preserve exchange gain loss journals in lcv (#58505)
(cherry picked from commit 2b54a581f8)
2026-09-02 06:14:58 +00:00
mergify[bot]
547d82da56 fix: add missing perm checks on various whitelisted methods (backport #58652) (#58653)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-01 14:16:38 +00:00
Shllokkk
d704dc474e Merge pull request #58648 from frappe/mergify/bp/version-15-hotfix/pr-58607
fix: round party balances to currency precision in Trial Balance for Party (backport #58607)
2026-09-01 16:42:52 +05:30
Aarol D'Souza
46a99ec216 Merge pull request #58636 from frappe/mergify/bp/version-15-hotfix/pr-58586
fix(sms_settings): add patch to pre-fill roles into SMS Settings Role Table (backport #58586)
2026-09-01 15:46:14 +05:30
Shllokkk
b98d193b77 fix: round party balances to currency precision in Trial Balance for Party
(cherry picked from commit b1c7657dfa)
2026-09-01 09:49:32 +00:00
Diptanil Saha
5289982059 fix(accounts)!: add permission checks on payment entry whitelisted methods (#58643) 2026-09-01 15:17:57 +05:30
AarDG10
2f2a9301d8 chore: resolve conflicts 2026-09-01 12:55:27 +05:30
AarDG10
0cef90d079 fix(sms_settings): add patch to pre-fill roles into SMS Settings Roles Table
(cherry picked from commit 3501beb2bd)

# Conflicts:
#	erpnext/patches.txt
2026-09-01 07:18:22 +00:00
mergify[bot]
307ffcd5c3 fix: translate label in party validation (backport #58624) (#58625)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-01 00:54:08 +02:00
mergify[bot]
d1b615b851 fix(crm): add missing permission validation on get_contract_template (backport #58621) (#58622)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 19:49:07 +00:00
mergify[bot]
fa903f40fd fix(bank_statement_import): add missing permission check on multiple whitelisted methods (backport #58221) (#58223)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 19:39:12 +00:00
Diptanil Saha
c5bc5f9c31 fix: validate POS Settings invoice and search fields on the server (backport #58611) (#58612) 2026-08-31 18:39:27 +00:00
mergify[bot]
28175a5c9d fix: do not map the same row twice in "Get Items From" (backport #58617) (#58618)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-31 19:00:31 +02:00
Lakshit Jain
4cff4b7cec Merge pull request #58601 from frappe/mergify/bp/version-15-hotfix/pr-58483
fix(taxes): skip tax addition for invoice created from opening invoice tool (backport #58483)
2026-08-31 18:39:01 +05:30
ljain112
4ad0180dff chore: resolve conflicts 2026-08-31 17:20:46 +05:30
ljain112
eed5969c28 chore: resolve conflicts 2026-08-31 17:08:48 +05:30
ljain112
ae01c21614 chore: resolve conflicts 2026-08-31 16:56:39 +05:30
ljain112
7090c6fdab fix(taxes): skip tax addition for invoice created from opening invoice tool
(cherry picked from commit e08a166281)

# Conflicts:
#	erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
#	erpnext/accounts/services/taxes.py
2026-08-31 09:35:34 +00:00
Nishka Gosalia
0f84b25964 Merge pull request #58593 from frappe/mergify/bp/version-15-hotfix/pr-58325
fix(projects): check read permission on source in create_duplicate_project (backport #58325)
2026-08-31 14:31:04 +05:30
Shllokkk
0ea03a688b chore(projects): add type hints to create_duplicate_project 2026-08-31 14:04:32 +05:30
Shllokkk
af8c010aa8 fix(projects): check read permission on source in create_duplicate_project
(cherry picked from commit 8c8b282a2e)
2026-08-31 07:11:10 +00:00
mergify[bot]
c656497aac fix(timesheet): scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport #58267) (#58576)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-30 18:36:29 +00:00
Mihir Kandoi
9c19b024db fix(manufacturing): use packed row delivery date (#58569) 2026-08-30 11:14:14 +00:00
Henil Maru
7454735595 fix: Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows (#58527) 2026-08-30 15:56:38 +05:30
mergify[bot]
2d46bb962c fix(stock): load available serial no report (backport #58558) (#58561)
fix(stock): load available serial no report (#58558)

(cherry picked from commit 8b43394533)

Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-29 14:56:06 +00:00
mergify[bot]
6a632d6d1c refactor(stock): remove dead warehouse_condition branch on get_stock_ledger_entries (backport #58552) (#58553)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-29 17:55:45 +05:30
Shllokkk
e19686053f Merge pull request #58543 from frappe/mergify/bp/version-15-hotfix/pr-58324
fix(crm): check write permission in edit_note (backport #58324)
2026-08-29 13:28:02 +05:30
Shllokkk
f0ac82605c chore: resolve merge conflicts 2026-08-29 13:08:42 +05:30
Shllokkk
2b2a005682 fix(crm): check write permission in edit_note
(cherry picked from commit eb49f51d29)

# Conflicts:
#	erpnext/crm/utils.py
2026-08-28 15:05:16 +00:00
mergify[bot]
c4e7cfa6a0 fix: persist redistributed additional costs during stock entry repost (backport #58433) (#58532)
* fix: persist redistributed additional costs during stock entry repost (#58433)

* fix: persist redistributed additional costs during stock entry repost

* test: cover additional cost persistence on stock entry recalculation

(cherry picked from commit 074c84e880)

# Conflicts:
#	erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py

* chore: fix conflicts

Removed unused import 'flt' from test file.

---------

Co-authored-by: Afsal Syed <146159709+Afsalsyed@users.noreply.github.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-28 10:42:27 +00:00
Sudharsanan Ashok
1aa7528119 Merge pull request #58494 from frappe/mergify/bp/version-15-hotfix/pr-58493
fix(selling): check quotation write permission before marking lost (backport #58493)
2026-08-28 13:29:46 +05:30
Sudharsanan Ashok
1a92047887 Merge pull request #58496 from frappe/mergify/bp/version-15-hotfix/pr-58492
fix(selling): check sales order permission before work order creation (backport #58492)
2026-08-28 13:15:00 +05:30
pandiyan
33ebc7d7e3 fix(selling): check quotation write permission before marking lost (#58493)
(cherry picked from commit 5f99a3418d)

# Conflicts:
#	erpnext/selling/doctype/quotation/quotation.py
2026-08-28 13:04:34 +05:30
pandiyan
0609b06564 fix(selling): check sales order permission before work order creation (#58492)
(cherry picked from commit a8ba713f80)

# Conflicts:
#	erpnext/selling/doctype/sales_order/mapper.py
2026-08-28 12:55:57 +05:30
Shllokkk
b8163e271d Merge pull request #58500 from Shllokkk/work-order-transferred-qty-sum-alternate-items
fix: sum item and its alternate transferred qty on work order
2026-08-28 12:47:53 +05:30
Aarol D'Souza
44cb0c5141 Merge pull request #58426 from frappe/mergify/bp/version-15-hotfix/pr-58351
fix(company): throw if linked to demo_company field (backport #58351)
2026-08-28 12:14:29 +05:30
mergify[bot]
1187fb8e01 fix(accounts): set pos profile on invoices respecting user permissions (backport #58508) (#58518)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-28 06:42:02 +00:00
Aarol D'Souza
be0fc95758 Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-58351 2026-08-28 11:48:39 +05:30
AarDG10
3edd8ee8be refactor: remove bold from formatter 2026-08-28 11:48:07 +05:30
Sudharsanan Ashok
d5318fbabc Merge pull request #58522 from frappe/mergify/bp/version-15-hotfix/pr-58521
Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)" (backport #58521)
2026-08-27 23:10:06 +05:30
mergify[bot]
d87a4b2541 fix: translate doctype in map msg (backport #58515) (#58516)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-27 19:26:19 +02:00
Sudharsanan11
c6717fb758 fix: resolve conflicts 2026-08-27 22:51:14 +05:30
Sudharsanan Ashok
0e7be81f86 Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)"
(cherry picked from commit 8287906a71)

# Conflicts:
#	erpnext/stock/stock_ledger.py
2026-08-27 17:16:30 +00:00
mergify[bot]
5a518951e4 fix(projects): ignore cancelled invoices in timesheet portal (backport #58501) (#58503)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-27 13:24:40 +00:00
Mihir Kandoi
099a5a71d9 fix(manufacturing): preserve job card qty in mr transfer (backport #58482) (#58498) 2026-08-27 12:39:48 +00:00
Shllokkk
70dff35736 test: transferred qty sums item and its alternate 2026-08-27 17:54:07 +05:30
Shllokkk
5851bb3b6e fix: sum item and its alternate transferred qty on work order required items 2026-08-27 17:47:05 +05:30
Sudharsanan Ashok
b00062df86 fix(stock): reset bin when a repost finds no stock ledger entries (#58434)
* fix(stock): reset bin when a repost finds no stock ledger entries

`build()` wipes `prev_sle_dict` in `initialize_reposting()` before `update_bin()`
runs, so a repost over an item and warehouse whose ledger is empty writes no bin
at all. `actual_qty`, `stock_value` and `valuation_rate` keep their last values,
bin totals drift from the stock balance, and reposting again cannot heal it.

Reset those bins as a terminal step in `update_bin()`, guarded by a re-check that
no live SLE exists so ordinary valuation is untouched.

Cancel is already correct on this branch, since `initialize_previous_data()` seeds
zeros when there is no previous SLE and the sle_id path never calls
`initialize_reposting()`. That seeding stays; this is a repost-path fix only.
develop drops it in #58362 because the `cancelled` guard makes it dead code there.

* test(stock): cover bin reset when the stock ledger is empty
2026-08-27 16:23:10 +05:30
Sudharsanan Ashok
2ce0bb1009 Merge pull request #58474 from frappe/mergify/bp/version-15-hotfix/pr-58469
fix: clarify duplicate internal party messages (backport #58469)
2026-08-27 16:13:40 +05:30
Sudharsanan Ashok
25ec044c09 Merge pull request #58428 from frappe/mergify/bp/version-15-hotfix/pr-58402
feat(analytics): filter sales and purchase analytics by entity  (backport #58402)
2026-08-27 16:12:13 +05:30
Aarol D'Souza
81cf874fd7 Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-58351 2026-08-27 15:42:27 +05:30
mergify[bot]
d62f6e715f fix: mark item tax templates as not applicable (backport #54673) (backport #54677) (#58462)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-27 09:46:30 +00:00
Pandiyan P
045d7086d1 fix(accounts): validate frozen accounts in period closing voucher (#58477) 2026-08-27 08:46:01 +00:00
Shllokkk
6f95c1ff92 Merge pull request #58473 from frappe/mergify/bp/version-15-hotfix/pr-58470
fix: validate items against source Sales Order in Material Request (backport #58470)
2026-08-27 14:04:44 +05:30
pandiyan
17208e7434 fix: clarify duplicate internal party messages (#58469)
(cherry picked from commit 4d4cf034b5)

# Conflicts:
#	erpnext/selling/doctype/customer/customer.py
2026-08-27 13:53:43 +05:30
Shllokkk
59a9819dae test: block item change on Sales Order-sourced Material Request row
(cherry picked from commit 07f8250e40)

# Conflicts:
#	erpnext/stock/doctype/material_request/test_material_request.py
2026-08-27 13:17:29 +05:30
Shllokkk
32d44c6e91 fix: validate items against source Sales Order in Material Request
(cherry picked from commit 65466464df)
2026-08-27 07:36:46 +00:00
Sudharsanan11
605918d3a2 fix: resolve analytics backport conflicts 2026-08-26 16:10:56 +05:30
Sudharsanan Ashok
0dddd00073 feat(analytics): filter sales and purchase analytics by entity (#58402)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit 3f29cdf8d2)

# Conflicts:
#	erpnext/buying/report/purchase_analytics/purchase_analytics.js
#	erpnext/buying/report/purchase_analytics/test_purchase_analytics.py
#	erpnext/selling/report/sales_analytics/sales_analytics.py
#	erpnext/selling/report/sales_analytics/test_sales_analytics.py
2026-08-26 16:10:56 +05:30
Shllokkk
3bf19df761 Merge pull request #58440 from Shllokkk/incorrect-serial-batch-bundle-letter-head
fix: reset hardcoded letter head on Incorrect Serial and Batch Bundle report
2026-08-26 15:49:34 +05:30
Shllokkk
ccdaa92de8 fix: reset hardcoded letter head on Incorrect Serial and Batch Bundle report 2026-08-26 14:50:32 +05:30
Sudharsanan Ashok
6d59065d79 Merge pull request #58424 from frappe/mergify/bp/version-15-hotfix/pr-58405
fix(stock): keep pick list links when refetching stock entry items (backport #58374) (backport #58405)
2026-08-26 13:40:13 +05:30
Jatin3128
228ab2d97e fix: keep source rate on re-fetch when maintain same rate is enabled (backport #57479) (#58332)
* fix: keep source rate on re-fetch when maintain same rate is enabled (backport #57479)

With "maintain same rate" on, re-fetching item details on a row mapped from a
source document (e.g. a Purchase Order) pulled the latest Item Price, giving a
rate the document can never be saved with. Skip the price list fetch for such
rows and keep the source rate, both for a single-row re-fetch and the bulk
apply_price_list path (price list / party / conversion rate change).

The rate is read from the linked source row in the database (not the mutable
target row) and permission-checked against the source document, so an unsaved
edit can't lock in a different rate and a crafted request can't disclose
another document's pricing.

Fixes frappe/erpnext#57436

* fix: resolve linter findings in get_item_details

Add missing type hints on the whitelisted get_item_details
signature and rename maintain_same_rate_enabled's sole "args"
parameter, both flagged by the semgrep security/code-quality
rules. Also drops an extra blank line that ruff-format rejected.

* fix: widen get_item_details doc type hint to include Document

accounts_controller.py calls get_item_details(args, self, ...)
during validate, passing the transaction Document itself, not
a dict/JSON string. The narrower hint tripped Frappe's runtime
argument type validation on every whitelisted call with a live
Document, failing test-record creation across the suite.
2026-08-26 13:14:25 +05:30
Sudharsanan11
e126c4597b fix: resolve stock entry backport conflicts 2026-08-26 13:09:08 +05:30
Sudharsanan11
22377fa86d fix(stock): keep pick list links when refetching stock entry items
(cherry picked from commit 01e0844d40)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.js
2026-08-26 13:09:08 +05:30
AarDG10
de260bd43c fix(company): throw if linked to demo_company field
Throws an error msg if user is deleting demo company directly.

(cherry picked from commit aaa3100efc)
2026-08-26 07:24:05 +00:00
Sudharsanan Ashok
9e082a96f7 Merge pull request #58418 from frappe/mergify/bp/version-15-hotfix/pr-58394
fix(stock): validate serial inventory dimensions (backport #58394)
2026-08-26 12:53:35 +05:30
Pandiyan P
bc071d7793 fix(stock): validate serial inventory dimensions (#58394)
* fix(stock): validate serial inventory dimensions

* test(stock): cover serial inventory dimensions

(cherry picked from commit c940bd1e66)

# Conflicts:
#	erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py
#	erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
2026-08-26 12:28:31 +05:30
mergify[bot]
f5ce20f122 fix: keep Currency and Price List section open for foreign currency (backport #58074) (#58112)
* fix: keep Currency and Price List section open for foreign currency (#58074)

The section is marked collapsible with no condition, so it always
rendered collapsed. When the transaction currency differs from the
company currency the exchange rate is relevant and was hidden behind
a click.

Adds collapsible_depends_on so the section starts expanded whenever
the transaction currency differs from the company currency, and stays
collapsed otherwise.

(cherry picked from commit 4a8342496e)

# Conflicts:
#	erpnext/accounts/doctype/pos_invoice/pos_invoice.json
#	erpnext/accounts/doctype/sales_invoice/sales_invoice.json
#	erpnext/buying/doctype/purchase_order/purchase_order.json
#	erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
#	erpnext/selling/doctype/sales_order/sales_order.json
#	erpnext/stock/doctype/delivery_note/delivery_note.json
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.json

* fix: resolve unresolved cherry-pick conflict markers in modified timestamps

Mergify's automatic cherry-pick of #58074 left conflict markers
committed on the modified field, breaking JSON parsing.

---------

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-08-26 11:35:24 +05:30
Frappe PR Bot
945e825bee chore(release): Bumped to Version 15.120.0
# [15.120.0](https://github.com/frappe/erpnext/compare/v15.119.3...v15.120.0) (2026-08-25)

### Bug Fixes

* **accounts:** disallow reversing a reverse journal entry ([15041a6](15041a62dd))
* **accounts:** supplier group filter not applied on accounts payable report ([743f7d8](743f7d8713))
* aggregate child warehouses in Stock Qty vs Serial No Count report (backport [#58134](https://github.com/frappe/erpnext/issues/58134)) ([#58365](https://github.com/frappe/erpnext/issues/58365)) ([61238e7](61238e7c4c))
* allow custom remark on reversal journal entry ([#58308](https://github.com/frappe/erpnext/issues/58308)) ([b578fb5](b578fb52d5))
* asset scrap flow related changes (backport [#55126](https://github.com/frappe/erpnext/issues/55126)) ([#58144](https://github.com/frappe/erpnext/issues/58144)) ([167cc1e](167cc1e5b9))
* **assets:** split FIFO/LIFO rate across grouped stock item rows ([c2c4e5e](c2c4e5ee8b))
* attributeerror on budget against a non profit and loss account ([2095411](2095411a28))
* **bank_statement_import:** add missing permission check on `get_import_status` (backport [#58217](https://github.com/frappe/erpnext/issues/58217)) ([#58218](https://github.com/frappe/erpnext/issues/58218)) ([1464a34](1464a34fc6))
* block disabled/frozen party on Opportunity and Request for Quotation (backport [#57983](https://github.com/frappe/erpnext/issues/57983)) ([#58034](https://github.com/frappe/erpnext/issues/58034)) ([88a36a8](88a36a800a))
* **buying:** allow purchase returns against a closed purchase order ([#58140](https://github.com/frappe/erpnext/issues/58140)) ([81c5393](81c53931b5))
* correct Item Group doctype name in item tax template dashboard  (backport [#58192](https://github.com/frappe/erpnext/issues/58192)) ([#58212](https://github.com/frappe/erpnext/issues/58212)) ([46d883d](46d883d00d))
* **crm_settings:** create custom fields for Frappe CRM on enabling synchronization ([eb85ca6](eb85ca68f6))
* don't set work order status to In Process only due to skip material transfer ([#58246](https://github.com/frappe/erpnext/issues/58246)) ([73b7ec3](73b7ec32b6))
* drop removed Restaurant doctype from sales tax template dashboard  (backport [#58191](https://github.com/frappe/erpnext/issues/58191)) ([#58210](https://github.com/frappe/erpnext/issues/58210)) ([32a5b23](32a5b23b3c))
* **email_digest:** added permission check for `get_msg_html` (backport [#58197](https://github.com/frappe/erpnext/issues/58197)) ([#58199](https://github.com/frappe/erpnext/issues/58199)) ([c6211eb](c6211eb075))
* escape interpolated values in portal, print and desk templates (backport [#58273](https://github.com/frappe/erpnext/issues/58273)) ([#58277](https://github.com/frappe/erpnext/issues/58277)) ([37f2770](37f2770809))
* escape interpolated values in text positions across portal and desk templates (backport [#58286](https://github.com/frappe/erpnext/issues/58286)) ([#58287](https://github.com/frappe/erpnext/issues/58287)) ([1210c61](1210c6187d))
* escape on status image for workstations in production status (backport [#58279](https://github.com/frappe/erpnext/issues/58279)) ([#58280](https://github.com/frappe/erpnext/issues/58280)) ([d047caf](d047caf4aa))
* filter available batch report by company (backport [#57995](https://github.com/frappe/erpnext/issues/57995)) ([#58076](https://github.com/frappe/erpnext/issues/58076)) ([8c8a4b6](8c8a4b6f20))
* fix conflicts ([f8e614f](f8e614f0c7))
* get items from sales order in sales invoice (backport [#58163](https://github.com/frappe/erpnext/issues/58163)) ([#58187](https://github.com/frappe/erpnext/issues/58187)) ([89d3701](89d3701e3b))
* hide rfq status in supplier portal ([#58368](https://github.com/frappe/erpnext/issues/58368)) ([2dbd224](2dbd224643))
* hide supplier name in rfq portal (backport [#58373](https://github.com/frappe/erpnext/issues/58373)) ([#58375](https://github.com/frappe/erpnext/issues/58375)) ([91fc999](91fc999575))
* ignore historical negative batch stock in outward validation (backport [#58148](https://github.com/frappe/erpnext/issues/58148)) ([#58150](https://github.com/frappe/erpnext/issues/58150)) ([9cc9aa0](9cc9aa0fa5))
* include time logs ending at midnight in timesheet billing summary (backport [#58355](https://github.com/frappe/erpnext/issues/58355)) ([#58356](https://github.com/frappe/erpnext/issues/58356)) ([3d4245b](3d4245b9b4))
* **italy:** handle none price_list_rate in e-invoice xml generation (backport [#58242](https://github.com/frappe/erpnext/issues/58242)) ([#58369](https://github.com/frappe/erpnext/issues/58369)) ([cb8ae93](cb8ae93fa3))
* **manufacturing:** derive material transfers from actual coverage ([#58114](https://github.com/frappe/erpnext/issues/58114)) ([a1ecea1](a1ecea1794))
* **manufacturing:** fall back to item group defaults for work order w… ([#58236](https://github.com/frappe/erpnext/issues/58236)) ([48bd313](48bd3139f3))
* mirror rounding adjustment on distributed_discount_amount (backport [#58047](https://github.com/frappe/erpnext/issues/58047)) ([#58054](https://github.com/frappe/erpnext/issues/58054)) ([b03e098](b03e098684))
* new docs should refetch incoming rates (backport [#58097](https://github.com/frappe/erpnext/issues/58097)) ([#58294](https://github.com/frappe/erpnext/issues/58294)) ([fd82c7d](fd82c7d691))
* **party_ledger_summary:** added missing filters for `cost_center` and `projects` ([#58411](https://github.com/frappe/erpnext/issues/58411)) ([d4815cb](d4815cb231))
* patch to delete the `crm_deal` custom fields ([ff1a191](ff1a1914a1))
* prevent duplicate supplier quotations from portal ([e1246ae](e1246ae95d))
* remove ignore_permissions from get_party_details signature ([#55491](https://github.com/frappe/erpnext/issues/55491)) ([4cd39aa](4cd39aa147))
* render missing terms before printing ([#58367](https://github.com/frappe/erpnext/issues/58367)) ([b33475e](b33475e7cf))
* respect zero currency precision ([#58395](https://github.com/frappe/erpnext/issues/58395)) ([6a84621](6a8462116b))
* **stock:** confirm before changing item qty from the batch selector (backport [#58123](https://github.com/frappe/erpnext/issues/58123)) ([#58124](https://github.com/frappe/erpnext/issues/58124)) ([954a5ec](954a5ec006))
* **stock:** fetch item stock UOM in stock reconciliation (backport [#58284](https://github.com/frappe/erpnext/issues/58284)) ([#58290](https://github.com/frappe/erpnext/issues/58290)) ([7883f59](7883f595d7))
* **stock:** honour pick serial / batch based on in the batch selector (backport [#58176](https://github.com/frappe/erpnext/issues/58176)) ([#58181](https://github.com/frappe/erpnext/issues/58181)) ([6d06b43](6d06b43434))
* update stock variance account logic which defaults to default expense (backport [#57656](https://github.com/frappe/erpnext/issues/57656)) ([#57675](https://github.com/frappe/erpnext/issues/57675)) ([c132b99](c132b99b4e))
* use user data fields hook (backport [#58274](https://github.com/frappe/erpnext/issues/58274)) ([#58282](https://github.com/frappe/erpnext/issues/58282)) ([74c3eea](74c3eeaa44))
* validation for task end date check ([8dc9919](8dc9919691))

### Features

* **accounts:** opt-in 'Consider Accounting Dimension' filter on General Ledger Report (backport [#58156](https://github.com/frappe/erpnext/issues/58156)) ([#58157](https://github.com/frappe/erpnext/issues/58157)) ([e704e58](e704e589ca))
* add status filter to Supplier Quotation Comparison report ([9e6a166](9e6a16658d))
* Belgian Charts of Accounts (commercial + non-profit, FR + NL) (backport [#54679](https://github.com/frappe/erpnext/issues/54679)) ([#58185](https://github.com/frappe/erpnext/issues/58185)) ([4c9a76e](4c9a76ef9f))
* sync serial no status from stock ledger in Stock Qty vs Serial No Count report (version-15-hotfix) ([#57865](https://github.com/frappe/erpnext/issues/57865)) ([faaaa07](faaaa0776d))
* taxable-base resolver hook for custom charge types ([#56175](https://github.com/frappe/erpnext/issues/56175)) ([8c9e941](8c9e941614))
* validate purchase receipt exchange rate parity on purchase invoice (backport [#58177](https://github.com/frappe/erpnext/issues/58177)) ([#58189](https://github.com/frappe/erpnext/issues/58189)) ([a5f4d3a](a5f4d3abeb))
2026-08-25 16:59:18 +00:00
Diptanil Saha
5d60bd9f54 Merge pull request #58396 from frappe/version-15-hotfix
chore: release v15
2026-08-25 22:27:36 +05:30
Diptanil Saha
d4815cb231 fix(party_ledger_summary): added missing filters for cost_center and projects (#58411) 2026-08-25 22:09:13 +05:30
Raffael Meyer
3ae3009129 fix!: tax net_amount and not_applicable (#54687) 2026-08-25 13:50:03 +02:00
Sudharsanan Ashok
07b6b3defd Merge pull request #58398 from frappe/mergify/bp/version-15-hotfix/pr-58395
fix: respect zero currency precision (backport #58395)
2026-08-25 16:45:17 +05:30
Pandiyan P
6a8462116b fix: respect zero currency precision (#58395)
(cherry picked from commit ce23fcc055)

# Conflicts:
#	erpnext/accounts/test/test_utils.py
2026-08-25 15:51:20 +05:30
Sudharsanan Ashok
1b5239c498 Merge pull request #58354 from frappe/mergify/bp/version-15-hotfix/pr-58179
fix(accounts): supplier group filter not applied on accounts payable … (backport #58179)
2026-08-25 13:04:42 +05:30
Sudharsanan Ashok
7fcbf085a5 Merge pull request #58389 from frappe/mergify/bp/version-15-hotfix/pr-58384
fix: prevent duplicate supplier quotations from portal (backport #58377) (backport #58384)
2026-08-25 12:04:54 +05:30
Sudharsanan11
f8e614f0c7 fix: fix conflicts 2026-08-25 11:47:06 +05:30
pandiyan
c861fbf438 test: verify duplicate supplier quotations are rejected
(cherry picked from commit efe5571ca7)
2026-08-25 05:43:13 +00:00
pandiyan
e1246ae95d fix: prevent duplicate supplier quotations from portal
(cherry picked from commit 39e15c7b2d)

# Conflicts:
#	erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
2026-08-25 05:43:12 +00:00
mergify[bot]
91fc999575 fix: hide supplier name in rfq portal (backport #58373) (#58375)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-24 17:40:38 +05:30
mergify[bot]
4b569c3ec3 Fix/return qty validation different uom (backport #58298) (#58363)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
2026-08-24 17:39:56 +05:30
mergify[bot]
cb8ae93fa3 fix(italy): handle none price_list_rate in e-invoice xml generation (backport #58242) (#58369)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-08-24 17:39:24 +05:30
Sudharsanan Ashok
91548461c0 Merge pull request #58371 from frappe/mergify/bp/version-15-hotfix/pr-58368
fix: hide rfq status in supplier portal (backport #58368)
2026-08-24 16:35:55 +05:30
Pandiyan P
2dbd224643 fix: hide rfq status in supplier portal (#58368)
(cherry picked from commit 75d6183bb6)
2026-08-24 10:59:54 +00:00
Pandiyan P
b33475e7cf fix: render missing terms before printing (#58367) 2026-08-24 16:28:17 +05:30
mergify[bot]
61238e7c4c fix: aggregate child warehouses in Stock Qty vs Serial No Count report (backport #58134) (#58365)
Co-authored-by: Mohd Haris <haris@frappe.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-24 10:33:16 +00:00
mergify[bot]
3d4245b9b4 fix: include time logs ending at midnight in timesheet billing summary (backport #58355) (#58356)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-24 14:46:36 +05:30
ervishnucs
743f7d8713 fix(accounts): supplier group filter not applied on accounts payable report
(cherry picked from commit 513f19924d)
2026-08-24 07:12:19 +00:00
Vishnu Priya Baskaran
b578fb52d5 fix: allow custom remark on reversal journal entry (#58308)
fix: allow user remark on reversal journal entry
2026-08-19 22:18:13 +05:30
mergify[bot]
fd82c7d691 fix: new docs should refetch incoming rates (backport #58097) (#58294)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 11:58:32 +00:00
mergify[bot]
88a36a800a fix: block disabled/frozen party on Opportunity and Request for Quotation (backport #57983) (#58034)
* fix: block disabled/frozen customers on Opportunity

Opportunity inherits TransactionBase instead of AccountsController, so
it never ran validate_party_frozen_disabled like Quotation, Sales Order
and Sales Invoice do. A disabled Customer could be saved as an
Opportunity's party and only get caught later at Quotation stage.

Also fixes the party_name Link query on the client: it referenced
erpnext.queries.customer, which was never defined, so disabled
customers showed up in the picker.

(cherry picked from commit 90937ce6d9)

# Conflicts:
#	erpnext/crm/doctype/opportunity/test_opportunity.py

* fix: block disabled/frozen suppliers on Request for Quotation

Request for Quotation overrides validate() entirely and never calls
super().validate(), so it never goes through AccountsController's
party validation. Suppliers also sit in a child table, so the shared
PartyValidator wouldn't have caught it anyway (it only checks a single
top-level party field). A disabled or frozen Supplier could be added
to an RFQ and the RFQ submitted without any warning.

Also filters the suppliers grid's supplier Link field to disabled=0,
matching the same client-side fix applied to Opportunity's party_name.

(cherry picked from commit 4bf65ffc1d)

# Conflicts:
#	erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
#	erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py

* fix: scope Opportunity party validation to Customer only

validate_party_frozen_disabled only enforces Customer/Supplier/Employee,
so passing opportunity_from straight through silently no-op'd for Lead
and Prospect. Made the Customer-only scope explicit instead of relying
on that implicit fallthrough.

Lead.disabled is not enforced anywhere else in the codebase (lead_query,
the picker used for this same field, only filters status/docstatus), so
deliberately not extending validation to Lead-sourced Opportunities.

(cherry picked from commit 8c0a945417)

* refactor: move RFQ supplier disabled filter to link_filters

Static filters with no doc-dependent values belong on the field
definition, not in JS. Matches the existing pattern used for
Warehouse/Item link_filters elsewhere (e.g. job_card_item.json,
product_bundle_item.json).

(cherry picked from commit 6b35c51ff1)

* fix: resolve backport conflicts for disabled/frozen party validation

The automated backport left unresolved merge conflict markers committed
in request_for_quotation.py, test_request_for_quotation.py and
test_opportunity.py. Also fixes validate_party_frozen_disabled being
called with 3 args here, this branch's version only takes
(party_type, party_name), unlike develop's (company, party_type, party_name).

Dropped test_duplicate_supplier_rejected, test_rfq_blocked_for_supplier_with_prevent_rfqs
and test_rfq_status_lifecycle from the conflict resolution, they don't exist on
this branch and aren't part of this backport.

---------

Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-08-19 16:58:21 +05:30
mergify[bot]
7883f595d7 fix(stock): fetch item stock UOM in stock reconciliation (backport #58284) (#58290)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 10:36:34 +00:00
mergify[bot]
c132b99b4e fix: update stock variance account logic which defaults to default expense (backport #57656) (#57675)
Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-19 14:41:41 +05:30
mergify[bot]
74c3eeaa44 fix: use user data fields hook (backport #58274) (#58282)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 10:25:47 +05:30
mergify[bot]
1210c6187d fix: escape interpolated values in text positions across portal and desk templates (backport #58286) (#58287)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-19 02:44:35 +05:30
mergify[bot]
d047caf4aa fix: escape on status image for workstations in production status (backport #58279) (#58280)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-18 20:27:31 +00:00
mergify[bot]
37f2770809 fix: escape interpolated values in portal, print and desk templates (backport #58273) (#58277)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-19 00:35:45 +05:30
mergify[bot]
b03e098684 fix: mirror rounding adjustment on distributed_discount_amount (backport #58047) (#58054)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-18 12:07:43 +02:00
Frappe PR Bot
0526834071 chore(release): Bumped to Version 15.119.3
## [15.119.3](https://github.com/frappe/erpnext/compare/v15.119.2...v15.119.3) (2026-08-18)

### Bug Fixes

* get items from sales order in sales invoice (backport [#58163](https://github.com/frappe/erpnext/issues/58163)) (backport [#58187](https://github.com/frappe/erpnext/issues/58187)) ([#58259](https://github.com/frappe/erpnext/issues/58259)) ([88f32f1](88f32f1d1b))
2026-08-18 07:37:05 +00:00
mergify[bot]
88f32f1d1b fix: get items from sales order in sales invoice (backport #58163) (backport #58187) (#58259)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-18 07:35:00 +00:00
Lakshit Jain
41aba183de Merge pull request #58160 from frappe/mergify/bp/version-15-hotfix/pr-56175
feat: taxable-base resolver hook for custom charge types (backport #56175)
2026-08-18 11:58:24 +05:30
rohitwaghchaure
73b7ec32b6 fix: don't set work order status to In Process only due to skip material transfer (#58246) 2026-08-17 12:43:13 +00:00
Pandiyan P
48bd3139f3 fix(manufacturing): fall back to item group defaults for work order w… (#58236) 2026-08-17 15:26:05 +05:30
mergify[bot]
1464a34fc6 fix(bank_statement_import): add missing permission check on get_import_status (backport #58217) (#58218)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 15:11:25 +00:00
mergify[bot]
0c625ff69b fix(crm)!: remove unused get_last_interaction endpoint (backport #58214) (#58215)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 14:21:44 +00:00
Shllokkk
2aa1bf54f2 Merge pull request #58209 from frappe/mergify/bp/version-15-hotfix/pr-58208
feat: add status filter to Supplier Quotation Comparison report (backport #58208)
2026-08-16 19:48:40 +05:30
mergify[bot]
46d883d00d fix: correct Item Group doctype name in item tax template dashboard (backport #58192) (#58212) 2026-08-16 18:42:58 +05:30
mergify[bot]
32a5b23b3c fix: drop removed Restaurant doctype from sales tax template dashboard (backport #58191) (#58210) 2026-08-16 17:22:06 +05:30
Shllokkk
9e6a16658d feat: add status filter to Supplier Quotation Comparison report
(cherry picked from commit 2b84ed78e8)
2026-08-16 10:22:38 +00:00
mergify[bot]
c6211eb075 fix(email_digest): added permission check for get_msg_html (backport #58197) (#58199)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-15 16:47:02 +00:00
mergify[bot]
a5f4d3abeb feat: validate purchase receipt exchange rate parity on purchase invoice (backport #58177) (#58189)
* feat: validate purchase receipt exchange rate parity on purchase invoice (#58177)

(cherry picked from commit 70a8a2d0c5)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py

* chore: fix conflicts

Removed assertion for exchange rate discrepancy in purchase invoice test.

* test: fix backport of exchange rate difference test for non stock item

The conflict resolution left behind stale amount/discrepancy lookups
referencing a removed second item row (IndexError in CI and F841 ruff
failures). Align the test with the develop version: single non stock
item, PR at 80 / PI at 70, and assert no exchange gain/loss GL entry.

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

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:51:12 +00:00
mergify[bot]
89d3701e3b fix: get items from sales order in sales invoice (backport #58163) (#58187)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-15 08:02:23 +00:00
mergify[bot]
4c9a76ef9f feat: Belgian Charts of Accounts (commercial + non-profit, FR + NL) (backport #54679) (#58185)
Co-authored-by: Antoine Maas <antoine.maas@okte.io>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-14 16:56:24 +00:00
mergify[bot]
6d06b43434 fix(stock): honour pick serial / batch based on in the batch selector (backport #58176) (#58181)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-14 13:57:27 +00:00
Khushi Rawat
78d780d49e Merge pull request #58175 from aerele/backport-58092-version-15-hotfix
fix(accounts): disallow reversing a reverse journal entry
2026-08-14 17:43:53 +05:30
pandiyan
15041a62dd fix(accounts): disallow reversing a reverse journal entry
check read permission on the source entry before the guard runs, so the reversal relationship is not disclosed to a user who cannot read it.
2026-08-14 17:06:23 +05:30
pandiyan
754e7052ca test(accounts): cover reversal of a reverse journal entry
also assert that a user without read access on the entry gets a permission error instead of the reversal relationship.
2026-08-14 16:56:06 +05:30
Nishka Gosalia
3b2bb23306 Merge pull request #58170 from frappe/mergify/bp/version-15-hotfix/pr-58167
fix: validation for task end date check (backport #58167)
2026-08-14 16:42:31 +05:30
mergify[bot]
e704e589ca feat(accounts): opt-in 'Consider Accounting Dimension' filter on General Ledger Report (backport #58156) (#58157)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-14 16:27:37 +05:30
nishkagosalia
8dc9919691 fix: validation for task end date check
(cherry picked from commit 7c6da80f98)
2026-08-14 10:52:46 +00:00
vorasmit
e7e2358cb5 chore: resolve conflicts 2026-08-14 15:12:42 +05:30
Smit Vora
8c9e941614 feat: taxable-base resolver hook for custom charge types (#56175)
(cherry picked from commit 986cea2331)

# Conflicts:
#	erpnext/controllers/taxes_and_totals.py
#	erpnext/controllers/tests/test_taxes_and_totals.py
#	erpnext/public/js/controllers/taxes_and_totals.js
2026-08-14 08:19:25 +00:00
rohitwaghchaure
faaaa0776d feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report (version-15-hotfix) (#57865)
* feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report

* fix: pick last bundle move in SQL ordered by posting datetime and SLE creation

* fix: derive synced serial no status from stock ledger helper and validate sync args

* fix: use posting datetime for last bundle move after version-15 field rename

* fix: order last bundle moves by bundle posting datetime
2026-08-14 11:51:18 +05:30
Khushi Rawat
15d430b959 Merge pull request #58093 from aerele/fix/budget-non-pnl-account-message
fix(accounts): attributeerror on budget against a non profit and loss account
2026-08-14 11:27:35 +05:30
mergify[bot]
9cc9aa0fa5 fix: ignore historical negative batch stock in outward validation (backport #58148) (#58150)
fix: ignore historical negative batch stock in outward validation (#58148)

(cherry picked from commit 9239d1c2a3)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-14 05:48:33 +00:00
Pandiyan P
81c53931b5 fix(buying): allow purchase returns against a closed purchase order (#58140) 2026-08-14 09:01:03 +05:30
mergify[bot]
167cc1e5b9 fix: asset scrap flow related changes (backport #55126) (#58144)
Co-authored-by: khushi8112 <khushirawat23@navgurukul.org>
2026-08-13 18:41:15 +00:00
Diptanil Saha
c6e6bdccae Merge pull request #58142 from frappe/mergify/bp/version-15-hotfix/pr-58141
fix(crm_settings): create `crm_deal` fields on enabling frappe crm data synchronization (backport #58141)
2026-08-13 23:28:36 +05:30
diptanilsaha
ff1a1914a1 fix: patch to delete the crm_deal custom fields
(cherry picked from commit 9613d72d81)
2026-08-13 17:39:51 +00:00
diptanilsaha
eb85ca68f6 fix(crm_settings): create custom fields for Frappe CRM on enabling synchronization
(cherry picked from commit be2dea0ba2)
2026-08-13 17:39:50 +00:00
mergify[bot]
954a5ec006 fix(stock): confirm before changing item qty from the batch selector (backport #58123) (#58124)
fix(stock): confirm before changing item qty from the batch selector (#58123)

the batch selector silently overwrote the item qty with the bundle total,
so editing a row qty in the dialog changed the delivered qty without any
warning. prompt for confirmation when the rows do not add up to the qty
to fetch, and only proceed if the user agrees.

(cherry picked from commit a2976dd29e)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-13 16:40:26 +05:30
Mihir Kandoi
a1ecea1794 fix(manufacturing): derive material transfers from actual coverage (#58114) 2026-08-13 09:39:21 +00:00
Diptanil Saha
a7e21e834d Merge pull request #58111 from frappe/mergify/bp/version-15-hotfix/pr-55491
fix: remove ignore_permissions from get_party_details signature (backport #55491)
2026-08-13 13:26:09 +05:30
diptanilsaha
23919967f6 chore: resolve conflicts 2026-08-13 13:01:53 +05:30
Shllokkk
4cd39aa147 fix: remove ignore_permissions from get_party_details signature (#55491)
(cherry picked from commit efb8336bf8)

# Conflicts:
#	erpnext/accounts/doctype/sales_invoice/sales_invoice.py
#	erpnext/accounts/party.py
2026-08-13 07:23:22 +00:00
Khushi Rawat
afa2437910 Merge pull request #57564 from khushi8112/refactor-depreciation-entry-v15
refactor: split make_depreciation_entry into public and internal helpers
2026-08-13 12:33:09 +05:30
Khushi Rawat
9638e81762 Merge pull request #58088 from frappe/mergify/bp/version-15-hotfix/pr-57820
fix(assets): split fifo/lifo rate across grouped stock item rows (backport #57820)
2026-08-13 12:28:26 +05:30
ervishnucs
1e23d48a5b test(assets): cover grouped stock item rows splitting FIFO rate
(cherry picked from commit 2cbc5b89d6)

# Conflicts:
#	erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py
2026-08-13 12:01:31 +05:30
khushi8112
6650ac8c36 refactor: split make_depreciation_entry into public and internal helpers 2026-08-13 11:39:33 +05:30
mergify[bot]
bfa3edbf95 Fix/item description in the item price list (backport #58084) (#58101)
Co-authored-by: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-13 04:42:02 +00:00
mergify[bot]
8c8a4b6f20 fix: filter available batch report by company (backport #57995) (#58076)
Co-authored-by: Krishna Shirsath <shirsathkrishna19@gmail.com>
2026-08-12 17:33:56 +05:30
pandiyan
2095411a28 fix: attributeerror on budget against a non profit and loss account 2026-08-12 16:12:55 +05:30
pandiyan
5700384989 test: budget against a balance sheet account 2026-08-12 16:12:55 +05:30
ervishnucs
c2c4e5ee8b fix(assets): split FIFO/LIFO rate across grouped stock item rows
(cherry picked from commit a05ec49062)
2026-08-12 09:04:48 +00:00
186 changed files with 11103 additions and 2253 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.119.2"
__version__ = "15.121.0"
def get_default_company(user=None):

View File

@@ -89,13 +89,14 @@
"enable_fuzzy_matching",
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"disable_include_dimensions",
"column_break_lvjk",
"receivable_payable_remarks_length",
"general_ledger_remarks_length",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"default_ageing_range",
"column_break_ntmi",
"receivable_payable_remarks_length",
"legacy_section",
"ignore_is_opening_check_for_reporting",
"payment_request_settings",
@@ -483,7 +484,7 @@
{
"fieldname": "remarks_section",
"fieldtype": "Section Break",
"label": "Remarks Column Length"
"label": "General Ledger Report"
},
{
"default": "0",
@@ -566,7 +567,7 @@
{
"fieldname": "accounts_receivable_payable_tuning_section",
"fieldtype": "Section Break",
"label": "Accounts Receivable / Payable Tuning"
"label": "Accounts Receivable / Payable Report"
},
{
"fieldname": "legacy_section",
@@ -665,6 +666,12 @@
"fieldname": "default_ageing_range",
"fieldtype": "Data",
"label": "Default Ageing Range"
},
{
"default": "0",
"fieldname": "disable_include_dimensions",
"fieldtype": "Check",
"label": "Disable \"Consider Accounting Dimension\" Filter"
}
],
"icon": "icon-cog",
@@ -672,7 +679,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-24 12:59:41.868865",
"modified": "2026-08-14 13:12:47.895908",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -44,6 +44,7 @@ class AccountsSettings(Document):
default_ageing_range: DF.Data | None
delete_linked_ledger_entries: DF.Check
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
disable_include_dimensions: DF.Check
enable_common_party_accounting: DF.Check
enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check

View File

@@ -94,11 +94,11 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "section_break_8",
@@ -187,12 +187,14 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-11-25 11:10:10.945027",
"modified": "2026-05-01 00:38:53.368737",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Advance Taxes and Charges",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "ASC"
}
"sort_order": "ASC",
"states": []
}

View File

@@ -30,6 +30,7 @@ class AdvanceTaxesandCharges(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project: DF.Link | None
rate: DF.Float
row_id: DF.Data | None
tax_amount: DF.Currency

View File

@@ -105,25 +105,30 @@ class BankStatementImport(DataImport):
@frappe.whitelist()
def get_preview_from_template(data_import, import_file=None, google_sheets_url=None):
return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template(
import_file, google_sheets_url
)
bsi = frappe.get_doc("Bank Statement Import", data_import)
bsi.check_permission()
return bsi.get_preview_from_template(import_file, google_sheets_url)
@frappe.whitelist()
def form_start_import(data_import):
return frappe.get_doc("Bank Statement Import", data_import).start_import()
bsi = frappe.get_doc("Bank Statement Import", data_import)
bsi.check_permission("write")
return bsi.start_import()
@frappe.whitelist()
def download_errored_template(data_import_name):
data_import = frappe.get_doc("Bank Statement Import", data_import_name)
data_import.check_permission()
data_import.export_errored_rows()
@frappe.whitelist()
def download_import_log(data_import_name):
return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log()
bsi = frappe.get_doc("Bank Statement Import", data_import_name)
bsi.check_permission()
return bsi.download_import_log()
def parse_data_from_template(raw_data):
@@ -252,6 +257,7 @@ def get_import_status(docname):
import_status = {}
data_import = frappe.get_doc("Bank Statement Import", docname)
data_import.check_permission()
import_status["status"] = data_import.status
logs = frappe.get_all(

View File

@@ -98,13 +98,13 @@ class Budget(Document):
frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(d.account))
elif account_details.company != self.company:
frappe.throw(
_("Account {0} does not belongs to company {1}").format(d.account, self.company)
_("Account {0} does not belong to company {1}").format(d.account, self.company)
)
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_(
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
).format(self.account)
).format(d.account)
)
if d.account in account_list:

View File

@@ -357,6 +357,16 @@ class TestBudget(unittest.TestCase):
self.assertRaises(BudgetError, jv.submit)
def test_budget_against_balance_sheet_account(self):
budget = frappe.new_doc("Budget")
budget.budget_against = "Cost Center"
budget.cost_center = "_Test Cost Center - _TC"
budget.company = "_Test Company"
budget.fiscal_year = get_fiscal_year(nowdate())[0]
budget.append("accounts", {"account": "_Test Bank - _TC", "budget_amount": 200000})
self.assertRaisesRegex(frappe.ValidationError, "_Test Bank - _TC", budget.insert)
def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None):
if budget_against_field == "project":

View File

@@ -234,8 +234,10 @@ frappe.ui.form.on("Dunning", {
dn: frm.doc.name,
},
callback: function (r) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
if (!r.exc) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
}
},
});
},

View File

@@ -47,3 +47,12 @@ frappe.ui.form.on("Item Tax Template", {
});
},
});
frappe.ui.form.on("Item Tax Template Detail", {
not_applicable: function (frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.not_applicable) {
frappe.model.set_value(cdt, cdn, "tax_rate", 0);
}
},
});

View File

@@ -27,8 +27,15 @@ class ItemTaxTemplate(Document):
# end: auto-generated types
def validate(self):
self.set_zero_rate_for_not_applicable_tax()
self.validate_tax_accounts()
def set_zero_rate_for_not_applicable_tax(self):
"""Ensure tax_rate is 0 for any row marked as not applicable."""
for row in self.get("taxes"):
if row.not_applicable:
row.tax_rate = 0
def autoname(self):
if self.company and self.title:
abbr = frappe.get_cached_value("Company", self.company, "abbr")

View File

@@ -8,6 +8,6 @@ def get_data():
{"label": _("Pre Sales"), "items": ["Quotation", "Supplier Quotation"]},
{"label": _("Sales"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
{"label": _("Purchase"), "items": ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]},
{"label": _("Stock"), "items": ["Item Groups", "Item"]},
{"label": _("Stock"), "items": ["Item Group", "Item"]},
],
}

View File

@@ -6,7 +6,8 @@
"engine": "InnoDB",
"field_order": [
"tax_type",
"tax_rate"
"tax_rate",
"not_applicable"
],
"fields": [
{
@@ -21,12 +22,21 @@
"fieldname": "tax_rate",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Tax Rate"
"label": "Tax Rate",
"read_only_depends_on": "eval:doc.not_applicable"
},
{
"default": "0",
"description": "Check if this tax is not applicable to items (distinct from 0% rate)",
"fieldname": "not_applicable",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Not Applicable"
}
],
"istable": 1,
"links": [],
"modified": "2026-04-30 23:49:27.020639",
"modified": "2026-04-30 23:59:22.020639",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Item Tax Template Detail",

View File

@@ -14,6 +14,7 @@ class ItemTaxTemplateDetail(Document):
if TYPE_CHECKING:
from frappe.types import DF
not_applicable: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -65,7 +65,7 @@ frappe.ui.form.on("Journal Entry", {
);
}
if (frm.doc.docstatus == 1) {
if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) {
frm.add_custom_button(
__("Reverse Journal Entry"),
function () {
@@ -516,7 +516,7 @@ $.extend(erpnext.journal_entry, {
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => field.df.fieldname != "posting_date")
.filter((field) => !["posting_date", "user_remark"].includes(field.df.fieldname))
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _, msgprint, scrub
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
from frappe.model.document import Document
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, getdate, nowdate
import erpnext
@@ -1525,6 +1526,7 @@ def get_payment_entry_against_order(
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
):
ref_doc = frappe.get_doc(dt, dn)
ref_doc.check_permission()
if flt(ref_doc.per_billed, 2) > 0:
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
@@ -1570,6 +1572,8 @@ def get_payment_entry_against_invoice(
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
):
ref_doc = frappe.get_doc(dt, dn)
ref_doc.check_permission()
if dt == "Sales Invoice":
party_type = "Customer"
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
@@ -1605,6 +1609,8 @@ def get_payment_entry_against_invoice(
def get_payment_entry(ref_doc, args):
frappe.has_permission("Journal Entry", ptype="create", throw=True)
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
"Company", ref_doc.company, "cost_center"
)
@@ -1892,7 +1898,21 @@ def make_inter_company_journal_entry(name, voucher_type, company):
@frappe.whitelist()
def make_reverse_journal_entry(source_name, target_doc=None):
def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Document | None = None) -> Document:
# `get_mapped_doc` checks this as well, but the guard below discloses which entry
# reverses which, so read access has to be settled before it runs
if not frappe.has_permission("Journal Entry", doc=source_name):
frappe.throw(_("Not permitted"), frappe.PermissionError)
reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of")
if reversal_of:
frappe.throw(
_("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format(
get_link_to_form("Journal Entry", source_name),
get_link_to_form("Journal Entry", reversal_of),
)
)
from frappe.model.mapper import get_mapped_doc
def post_process(source, target):

View File

@@ -249,6 +249,27 @@ class TestJournalEntry(unittest.TestCase):
self.check_gl_entries()
def test_disallow_reversal_of_a_reversal_journal_entry(self):
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
jv = make_journal_entry("_Test Bank - _TC", "Sales - _TC", 100, submit=True)
rjv = make_reverse_journal_entry(jv.name)
rjv.posting_date = nowdate()
rjv.submit()
self.assertRaisesRegex(
frappe.ValidationError,
"is already a Reverse Journal Entry",
make_reverse_journal_entry,
rjv.name,
)
# the guard must not disclose the reversal to a user who cannot read the entry
frappe.set_user("Guest")
self.addCleanup(frappe.set_user, "Administrator")
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
def test_disallow_change_in_account_currency_for_a_party(self):
# create jv in USD
jv = make_journal_entry("_Test Bank USD - _TC", "_Test Receivable USD - _TC", 100, save=False)

View File

@@ -56,7 +56,9 @@ class LedgerMerge(Document):
@frappe.whitelist()
def form_start_merge(docname):
return frappe.get_doc("Ledger Merge", docname).start_merge()
lm_doc = frappe.get_doc("Ledger Merge", docname)
lm_doc.check_permission("write")
return lm_doc.start_merge()
def start_merge(docname):

View File

@@ -282,6 +282,9 @@ def start_import(invoices):
invoice_number = d.invoice_number
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
# the outstanding amount is entered inclusive of tax, so taxes must not
# be added on top of it
doc.flags.dont_auto_add_taxes = True
doc.insert(set_name=invoice_number)
doc.submit()
frappe.db.commit()

View File

@@ -2,9 +2,10 @@
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, today
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
@@ -12,6 +13,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.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
from erpnext.projects.doctype.project.test_project import make_project
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
@@ -140,6 +142,55 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
@change_settings(
"Accounts Settings",
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
)
def test_opening_invoice_creation_without_taxes(self):
company = "_Test Opening Invoice Company"
template = frappe.get_doc(
{
"doctype": "Sales Taxes and Charges Template",
"company": company,
"title": "_Test Opening Invoice Tax",
"taxes": [
{
"charge_type": "On Net Total",
"account_head": create_account(
account_name="_Test Opening Tax Account",
parent_account="Duties and Taxes - _TOIC",
account_type="Tax",
company=company,
),
"description": "Test taxes",
"rate": 9,
}
],
}
).insert()
# makes the template the default for the party, as it would be on a live site
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
tool = self.make_invoices(company=company, return_doc=True)
invoices = tool.make_invoices()
self.assertEqual(len(invoices), 2)
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
for invoice in invoices:
si = frappe.get_doc("Sales Invoice", invoice)
self.assertFalse(si.taxes)
self.assertEqual(si.grand_total, 200)
self.assertEqual(si.outstanding_amount, 200)
# the same invoice created outside the tool keeps the default taxes,
# since adding them there is the user's decision
si = frappe.get_doc(tool.get_invoices()[0])
si.flags.ignore_mandatory = True
si.insert()
self.assertTrue(si.taxes)
self.assertEqual(si.grand_total, 218)
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True

View File

@@ -2894,10 +2894,13 @@ def get_payment_entry(
party_type=None,
payment_type=None,
reference_date=None,
ignore_permissions=False,
created_from_payment_request=False,
):
frappe.has_permission("Payment Entry", ptype="create", throw=True)
doc = frappe.get_doc(dt, dn)
doc.check_permission()
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance):
frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt)))

View File

@@ -83,6 +83,7 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
def make_payment_records(name, supplier, mode_of_payment=None):
doc = frappe.get_doc("Payment Order", name)
doc.check_permission()
make_journal_entry(doc, supplier, mode_of_payment)

View File

@@ -54,7 +54,7 @@ class PeriodClosingVoucher(AccountsController):
if for_cancellation and is_immutable_ledger_enabled():
posting_date = getdate()
check_freezing_date(posting_date, self.company)
check_freezing_date(posting_date)
def validate_start_and_end_date(self):
self.fy_start_date, self.fy_end_date = frappe.db.get_value(

View File

@@ -499,6 +499,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"depends_on": "customer",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
@@ -1571,7 +1572,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-02-22 04:18:50.691218",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -271,40 +271,40 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
user = frappe.session["user"]
company = filters.get("company") or frappe.defaults.get_user_default("company")
args = {
"user": user,
"start": start,
"company": company,
"page_len": page_len,
"txt": "%%%s%%" % txt,
}
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf, `tabPOS Profile User` pfu
where
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
and (pf.name like %(txt)s)
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
args,
if not allowed_pos_profiles:
return {}
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
pos_profile = (
frappe.qb.from_(pf)
.inner_join(pfu)
.on(pfu.parent == pf.name)
.select(pf.name)
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
.where(pf.name.isin(allowed_pos_profiles))
.limit(page_len)
.offset(start)
.run()
)
if not pos_profile:
del args["user"]
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
on
pf.name = pfu.parent
where
ifnull(pfu.user, '') = ''
and pf.company = %(company)s
and pf.name like %(txt)s
and pf.disabled = 0""",
args,
pos_profile = (
frappe.qb.from_(pf)
.left_join(pfu)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user.isnull() | (pfu.user == ""))
& (pf.company == company)
& pf.name.like(f"%{txt}%")
& (pf.disabled == 0)
& (pf.name.isin(allowed_pos_profiles))
)
.run()
)
return pos_profile

View File

@@ -12,20 +12,22 @@
{
"fieldname": "fieldname",
"fieldtype": "Data",
"hidden": 1,
"label": "Fieldname"
"in_list_view": 1,
"label": "Fieldname",
"read_only": 1
},
{
"fieldname": "field",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Field"
"label": "Field",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-04-21 11:12:54.632093",
"modified": "2026-08-31 20:41:12.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Search Fields",
@@ -34,4 +36,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -1,40 +1,9 @@
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
let search_fields_datatypes = [
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
];
let do_not_include_fields = [
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
];
function is_valid_invoice_field(df) {
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
}
frappe.ui.form.on("POS Settings", {
onload: function (frm) {
@@ -44,57 +13,46 @@ frappe.ui.form.on("POS Settings", {
get_invoice_fields: function (frm) {
frappe.model.with_doctype("POS Invoice", () => {
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
if (
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
["Button"].includes(d.fieldtype)
) {
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
} else {
return null;
}
});
const fields = frappe.get_doc("DocType", "POS Invoice").fields.filter(is_valid_invoice_field);
frm.fields_dict.invoice_fields.grid.update_docfield_property(
"fieldname",
"options",
[""].concat(fields)
[""].concat(
fields.map((df) => {
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
})
)
);
});
},
add_search_options: function (frm) {
frappe.model.with_doctype("Item", () => {
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (
search_fields_datatypes.includes(d.fieldtype) &&
!do_not_include_fields.includes(d.fieldname)
) {
return [d.label];
} else {
return null;
}
});
frappe.call({
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
callback: ({ message }) => {
const fields = message || [];
fields.unshift("");
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
frm.searchable_item_fields = Object.fromEntries(
fields.map((df) => [df.option, df.fieldname])
);
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
"field",
"options",
[""].concat(fields.map((df) => df.option))
);
},
});
},
});
frappe.ui.form.on("POS Search Fields", {
field: function (frm, doctype, name) {
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
return d;
} else {
return null;
}
})[0];
const doc = frappe.get_doc(doctype, name);
doc.fieldname = df.fieldname;
frm.refresh_field("fields");
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
frm.refresh_field("pos_search_fields");
},
});
@@ -110,6 +68,6 @@ frappe.ui.form.on("POS Field", {
doc.options = df.options;
doc.fieldtype = df.fieldtype;
doc.default_value = df.default;
frm.refresh_field("fields");
frm.refresh_field("invoice_fields");
},
});

View File

@@ -1,9 +1,50 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from collections import Counter
import frappe
from frappe import _
from frappe.model import no_value_fields
from frappe.model.document import Document
SEARCH_FIELD_TYPES = (
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
)
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
DO_NOT_INCLUDE_FIELDS = (
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
)
class POSSettings(Document):
# begin: auto-generated types
@@ -22,4 +63,95 @@ class POSSettings(Document):
# end: auto-generated types
def validate(self):
pass
self.validate_duplicate_invoice_fields()
self.validate_invoice_fields()
self.validate_duplicate_pos_search_fields()
self.validate_pos_search_fields()
def validate_duplicate_invoice_fields(self):
fieldnames = [field.fieldname for field in self.invoice_fields]
for fieldname, count in Counter(fieldnames).items():
if count > 1:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(fieldname)
)
def validate_invoice_fields(self):
# the POS screen only ever creates a POS Invoice
meta = frappe.get_meta("POS Invoice")
for field in self.invoice_fields:
df = meta.get_field(field.fieldname)
if not df or not is_valid_invoice_field(df):
frappe.throw(
title=_("Invalid POS Field"),
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_("POS Invoice"))
),
)
# read only in the form, so keep them in sync with the invoice
field.label = df.label
field.fieldtype = df.fieldtype
field.options = df.options
def validate_duplicate_pos_search_fields(self):
fieldnames = [field.fieldname for field in self.pos_search_fields]
for fieldname, count in Counter(fieldnames).items():
if count > 1:
frappe.throw(
title=_("Duplicate POS Search Fields"),
msg=_("'{0}' has been already added.").format(fieldname),
)
def validate_pos_search_fields(self):
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
for field in self.pos_search_fields:
df = searchable_fields.get(field.fieldname)
if not df:
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
field.idx, frappe.bold(field.fieldname or "")
),
)
if field.field != get_search_field_option(df):
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' does not match {2}.").format(
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
),
)
def is_valid_invoice_field(df):
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
def get_searchable_item_fields():
return [
df
for df in frappe.get_meta("Item").fields
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
]
def get_search_field_option(df):
# the fieldname keeps the option unique, two Item fields can share a label
return f"{df.label} ({df.fieldname})"
@frappe.whitelist()
def get_pos_search_field_options():
frappe.has_permission("POS Settings", throw=True)
return [
{"option": get_search_field_option(df), "fieldname": df.fieldname}
for df in get_searchable_item_fields()
]

View File

@@ -3,6 +3,119 @@
import unittest
import frappe
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
class TestPOSSettings(unittest.TestCase):
pass
def setUp(self):
self.settings = frappe.get_single("POS Settings")
self.settings.invoice_fields = []
self.settings.pos_search_fields = []
def tearDown(self):
frappe.db.rollback()
def assertInvalid(self, message):
with self.assertRaises(frappe.ValidationError) as context:
self.settings.save()
self.assertIn(message, str(context.exception))
def test_duplicate_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.assertInvalid("'customer' has been already added.")
def test_unknown_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
self.assertInvalid("is not a valid field of")
def test_layout_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
self.assertInvalid("is not a valid field of")
def test_invoice_field_properties_are_set_from_the_invoice(self):
self.settings.append(
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
)
self.settings.save()
field = self.settings.invoice_fields[0]
self.assertEqual(field.label, "Customer")
self.assertEqual(field.fieldtype, "Link")
self.assertEqual(field.options, "Customer")
def test_searchable_item_field_is_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
def test_excluded_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_of_unsearchable_type_is_not_allowed(self):
# maintain stock is a Check field
self.settings.append(
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
)
self.assertInvalid("cannot be used to search items")
def test_unknown_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_without_a_fieldname_is_not_allowed(self):
# the form fills the fieldname in, it cannot be picked on its own
self.settings.append("pos_search_fields", {"field": "Description (description)"})
self.assertInvalid("cannot be used to search items")
def test_search_field_option_must_match_its_fieldname(self):
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
self.assertInvalid("does not match")
def test_bare_label_is_not_accepted_as_a_search_field(self):
# the stored option carries the fieldname, the patch backfills older rows
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
self.assertInvalid("does not match")
def test_duplicate_search_fields_are_not_allowed(self):
for _ in range(2):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.assertInvalid("has been already added")
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
row = self.settings.pos_search_fields[0].name
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
append_fieldname()
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
def test_patch_leaves_an_already_migrated_search_field_alone(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
append_fieldname()
row = self.settings.pos_search_fields[0].name
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")

View File

@@ -139,6 +139,8 @@ def start_pcv_processing(docname: str):
@frappe.whitelist()
def pause_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
@@ -154,6 +156,8 @@ def pause_pcv_processing(docname: str):
@frappe.whitelist()
def cancel_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
@@ -168,6 +172,8 @@ def cancel_pcv_processing(docname: str):
@frappe.whitelist()
def resume_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()

View File

@@ -502,6 +502,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List",
@@ -1663,7 +1664,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-08-05 15:40:16.519774",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -40,6 +40,7 @@ from erpnext.assets.doctype.asset_category.asset_category import get_asset_categ
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.stock import get_warehouse_account_map
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
get_item_account_wise_additional_cost,
@@ -291,6 +292,7 @@ class PurchaseInvoice(BuyingController):
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
self.set_status()
self.validate_purchase_receipt_if_update_stock()
self.validate_exchange_rate_with_purchase_receipt()
validate_inter_company_party(
self.doctype, self.supplier, self.company, self.inter_company_invoice_reference
)
@@ -313,6 +315,47 @@ class PurchaseInvoice(BuyingController):
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_exchange_rate_with_purchase_receipt(self):
if self.is_internal_transfer() or not erpnext.is_perpetual_inventory_enabled(self.company):
return
stock_items = self.get_stock_items()
receipts = {
item.purchase_receipt
for item in self.items
if item.purchase_receipt and item.item_code in stock_items
}
if not receipts:
return
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"):
return
mismatched = [
f"{frappe.bold(row.name)} ({row.conversion_rate})"
for row in frappe.get_all(
"Purchase Receipt",
filters={"name": ("in", list(receipts))},
fields=["name", "currency", "conversion_rate"],
)
if row.currency == self.currency
and flt(row.conversion_rate)
and flt(row.conversion_rate) != flt(self.conversion_rate)
]
if not mismatched:
return
frappe.throw(
_(
"Exchange rate {0} does not match the exchange rate of Purchase Receipt {1}. Use the same exchange rate as the Purchase Receipt or enable {2} in {3} to adjust the landed cost based on this invoice."
).format(
frappe.bold(self.conversion_rate),
", ".join(mismatched),
frappe.bold(_("Set Landed Cost Based on Purchase Invoice Rate")),
get_link_to_form("Buying Settings", "Buying Settings", _("Buying Settings")),
)
)
def validate_invoice_hold(self):
if self.is_return:
frappe.throw(_("Return Purchase Invoice cannot be held."))
@@ -396,6 +439,9 @@ class PurchaseInvoice(BuyingController):
self.party_account_currency = account.account_currency
def check_on_hold_or_closed_status(self):
if self.get("is_return"):
return
check_list = []
for d in self.get("items"):
@@ -1375,7 +1421,20 @@ class PurchaseInvoice(BuyingController):
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
@@ -1400,7 +1459,20 @@ class PurchaseInvoice(BuyingController):
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
stock_adjustment_amt = warehouse_debit_amount - stock_amount
@@ -2130,6 +2202,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
if isinstance(args, str):
args = json.loads(args)
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
def received_and_mapped_qty(obj):
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
def post_parent_process(source_parent, target_parent):
remove_items_with_zero_qty(target_parent)
set_missing_values(source_parent, target_parent)
@@ -2144,13 +2221,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
target_parent.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) - flt(obj.received_qty)
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
target.qty = pending_qty
target.received_qty = pending_qty
target.stock_qty = pending_qty * flt(obj.conversion_factor)
target.amount = pending_qty * flt(obj.rate)
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
def select_item(d):
filtered_items = args.get("filtered_children", [])
@@ -2180,7 +2257,8 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
"condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty)
and select_item(doc),
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -513,6 +513,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
@@ -524,25 +530,15 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 80
self.assertRaises(frappe.ValidationError, pi.insert)
pi.conversion_rate = 70
pi.insert()
pi.submit()
# Get exchnage gain and loss account
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
# fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[0].base_net_amount - pr.items[0].base_net_amount
)
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
frappe.db.set_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
self.assertFalse(
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
@@ -550,11 +546,21 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
# Creating Purchase Invoice with USD currency
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = frappe.new_doc("Purchase Receipt")
pr.currency = "USD"
pr.company = "_Test Company with perpetual inventory"
pr.conversion_rate = (70,)
pr.conversion_rate = 80
pr.supplier = "_Test Supplier USD"
pr.append(
"items",
@@ -564,34 +570,20 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
"rate": 100,
},
)
pr.append(
"items",
{"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"},
)
pr.insert()
pr.submit()
# Createing purchase invoice against Purchase Receipt
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 80
pi.conversion_rate = 70
pi.credit_to = "_Test Payable USD - TCP1"
pi.insert()
pi.submit()
# Get exchnage gain and loss account
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
# fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
self.assertFalse(
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[1].base_net_amount - pr.items[1].base_net_amount
)
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
def test_purchase_invoice_change_naming_series(self):
pi = frappe.copy_doc(test_records[1])
pi.insert()
@@ -1662,6 +1654,96 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account)
def test_stock_adjustment_account_fallbacks_when_default_expense_account_unset(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice
class StockAdjustmentInvoice:
company = "_Test Company"
conversion_rate = 1
update_stock = 1
is_internal_supplier = 0
return_against = None
project = None
def __init__(self, is_return, defaults):
self.is_return = is_return
self.defaults = defaults
def get(self, fieldname):
return None
def get_company_default(self, fieldname, ignore_validation=False):
return self.defaults.get(fieldname)
def get_gl_dict(self, args, *unused_args, **unused_kwargs):
return frappe._dict(args)
def make_invoice(is_return, defaults):
return StockAdjustmentInvoice(is_return, defaults)
def make_item(is_fixed_asset=0, expense_account="Item Expense - _TC"):
return frappe._dict(
{
"name": "row-1",
"warehouse": "Stores - _TC",
"valuation_rate": 10,
"qty": 10,
"conversion_factor": 1,
"base_net_amount": 100,
"item_tax_amount": 0,
"landed_cost_voucher_amount": 0,
"sales_incoming_rate": 0,
"is_fixed_asset": is_fixed_asset,
"expense_account": expense_account,
"cost_center": "Main - _TC",
"project": None,
"precision": lambda fieldname: 2,
}
)
defaults = {
"default_expense_account": None,
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
"asset_received_but_not_billed": "Asset Received But Not Billed - _TC",
}
test_cases = (
(
"company default expense",
0,
make_item(),
{**defaults, "default_expense_account": "Default Expense - _TC"},
"Default Expense - _TC",
),
("stock rbnb", 0, make_item(), defaults, "Stock Received But Not Billed - _TC"),
(
"asset rbnb",
0,
make_item(is_fixed_asset=1),
defaults,
"Asset Received But Not Billed - _TC",
),
("return item expense", 1, make_item(), defaults, "Item Expense - _TC"),
(
"return without item expense",
1,
make_item(expense_account=None),
defaults,
"Stock Received But Not Billed - _TC",
),
)
for label, is_return, item, company_defaults, expected_account in test_cases:
with self.subTest(label=label):
invoice = make_invoice(is_return, company_defaults)
gl_entries = []
PurchaseInvoice.make_stock_adjustment_entry(
invoice, gl_entries, item, {(item.name, item.warehouse): 90}, "INR"
)
self.assertEqual(gl_entries[0].account, expected_account)
self.assertEqual(gl_entries[0].debit, 10)
self.assertEqual(gl_entries[0].debit_in_transaction_currency, 10)
@change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
@@ -2609,6 +2691,39 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
self.assertEqual(row.serial_no, "\n".join(serial_nos[:2]))
self.assertEqual(row.rejected_serial_no, serial_nos[2])
def test_purchase_invoice_return_against_closed_purchase_order(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
po = create_purchase_order(qty=2, rate=100)
invoices = []
for _ in range(2):
pi = make_pi_from_po(po.name)
pi.items[0].qty = 1
pi.submit()
invoices.append(pi)
make_return_doc("Purchase Invoice", invoices[0].name).submit()
po.reload()
po.update_status("Closed")
# a debit note against a closed Purchase Order should still go through,
# the same way a Sales Invoice return does against a closed Sales Order
debit_note = make_return_doc("Purchase Invoice", invoices[1].name)
debit_note.submit()
self.assertEqual(debit_note.docstatus, 1)
self.assertEqual(frappe.db.get_value("Purchase Order", po.name, "status"), "Closed")
# cancelling the debit note runs the same check on the closed order
debit_note.reload()
debit_note.cancel()
# a regular invoice against the closed order must still be blocked
blocked_pi = make_pi_from_po(po.name)
self.assertRaisesRegex(frappe.InvalidStatusError, "Closed", blocked_pi.save)
def test_make_pr_and_pi_from_po(self):
from erpnext.assets.doctype.asset.test_asset import create_asset_category

View File

@@ -25,10 +25,12 @@
"project",
"section_break_9",
"account_currency",
"net_amount",
"tax_amount",
"tax_amount_after_discount_amount",
"total",
"column_break_14",
"base_net_amount",
"base_tax_amount",
"base_total",
"base_tax_amount_after_discount_amount",
@@ -213,11 +215,11 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"default": "0",
@@ -241,20 +243,38 @@
"fieldtype": "Check",
"label": "Is Tax Withholding Account",
"read_only": 1
},
{
"description": "Basis for tax calculation",
"fieldname": "net_amount",
"fieldtype": "Currency",
"label": "Net Amount",
"options": "currency",
"read_only": 1
},
{
"description": "Basis for tax calculation",
"fieldname": "base_net_amount",
"fieldtype": "Currency",
"label": "Net Amount (Company Currency)",
"options": "Company:company:default_currency",
"read_only": 1
}
],
"grid_page_length": 50,
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-04-15 13:14:48.936047",
"modified": "2026-05-01 00:38:29.543523",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -17,6 +17,7 @@ class PurchaseTaxesandCharges(Document):
account_currency: DF.Link | None
account_head: DF.Link
add_deduct_tax: DF.Literal["Add", "Deduct"]
base_net_amount: DF.Currency
base_tax_amount: DF.Currency
base_tax_amount_after_discount_amount: DF.Currency
base_total: DF.Currency
@@ -35,9 +36,11 @@ class PurchaseTaxesandCharges(Document):
included_in_print_rate: DF.Check
is_tax_withholding_account: DF.Check
item_wise_tax_detail: DF.Code | None
net_amount: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project: DF.Link | None
rate: DF.Float
row_id: DF.Data | None
tax_amount: DF.Currency

View File

@@ -27,6 +27,7 @@ def start_payment_ledger_repost(docname=None):
"""
if docname:
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
repost_doc.check_permission("submit")
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
try:
for entry in repost_doc.repost_vouchers:

View File

@@ -597,6 +597,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"depends_on": "customer",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
@@ -2198,7 +2199,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-04-06 22:30:28.513139",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -25,9 +25,9 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import (
CROSS_PARTY_FIELD_NO_MAP,
_get_party_details,
get_due_date,
get_party_account,
get_party_details,
)
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
@@ -2266,9 +2266,9 @@ def make_delivery_note(source_name, target_doc=None):
"cost_center": "cost_center",
},
"postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1
and not doc.dn_detail
and doc.qty - doc.delivered_qty > 0,
"condition": lambda doc: (
doc.delivered_by_supplier != 1 and not doc.dn_detail and doc.qty - doc.delivered_qty > 0
),
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {
@@ -2737,7 +2737,7 @@ def update_taxes(
master_doctype=None,
):
# Update Party Details
party_details = get_party_details(
party_details = _get_party_details(
party=party,
party_type=party_type,
company=company,

View File

@@ -21,10 +21,12 @@
"rate",
"section_break_9",
"account_currency",
"net_amount",
"tax_amount",
"total",
"tax_amount_after_discount_amount",
"column_break_13",
"base_net_amount",
"base_tax_amount",
"base_total",
"base_tax_amount_after_discount_amount",
@@ -190,11 +192,11 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"default": "0",
@@ -220,19 +222,36 @@
"label": "Account Currency",
"options": "Currency",
"read_only": 1
},
{
"description": "Basis for tax calculation",
"fieldname": "net_amount",
"fieldtype": "Currency",
"label": "Net Amount",
"options": "currency",
"read_only": 1
},
{
"description": "Basis for tax calculation",
"fieldname": "base_net_amount",
"fieldtype": "Currency",
"label": "Net Amount (Company Currency)",
"options": "Company:company:default_currency",
"read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-01-14 10:08:17.776528",
"modified": "2026-05-01 00:37:57.880071",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Taxes and Charges",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -16,6 +16,7 @@ class SalesTaxesandCharges(Document):
account_currency: DF.Link | None
account_head: DF.Link
base_net_amount: DF.Currency
base_tax_amount: DF.Currency
base_tax_amount_after_discount_amount: DF.Currency
base_total: DF.Currency
@@ -33,9 +34,11 @@ class SalesTaxesandCharges(Document):
included_in_paid_amount: DF.Check
included_in_print_rate: DF.Check
item_wise_tax_detail: DF.Code | None
net_amount: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project: DF.Link | None
rate: DF.Float
row_id: DF.Data | None
tax_amount: DF.Currency

View File

@@ -7,10 +7,9 @@ def get_data():
"non_standard_fieldnames": {
"Tax Rule": "sales_tax_template",
"Subscription": "sales_tax_template",
"Restaurant": "default_tax_template",
},
"transactions": [
{"label": _("Transactions"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
{"label": _("References"), "items": ["POS Profile", "Subscription", "Restaurant", "Tax Rule"]},
{"label": _("References"), "items": ["POS Profile", "Subscription", "Tax Rule"]},
],
}

View File

@@ -83,7 +83,6 @@ def get_party_details(
price_list=None,
currency=None,
doctype=None,
ignore_permissions=False,
fetch_payment_terms_template=True,
party_address=None,
company_address=None,
@@ -93,8 +92,6 @@ def get_party_details(
):
if not party:
return frappe._dict()
if not frappe.db.exists(party_type, party):
frappe.throw(_("{0}: {1} does not exists").format(party_type, party))
return _get_party_details(
party,
account,
@@ -105,7 +102,7 @@ def get_party_details(
price_list,
currency,
doctype,
ignore_permissions,
False,
fetch_payment_terms_template,
party_address,
company_address,

View File

@@ -94,10 +94,15 @@ frappe.query_reports["Accounts Payable"] = {
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
let is_supplier = frappe.query_report.get_filter_value("party_type") === "Supplier";
let supplier_group_filter = frappe.query_report.get_filter("supplier_group");
if (supplier_group_filter) {
supplier_group_filter.df.hidden = !is_supplier;
}
frappe.query_report.toggle_filter_display("supplier_group", !is_supplier);
if (!is_supplier) {
frappe.query_report.set_filter_value("supplier_group", []);
}
},
},
{

View File

@@ -93,5 +93,27 @@ frappe.query_reports["Customer Ledger Summary"] = {
fieldtype: "Data",
hidden: 1,
},
{
fieldname: "cost_center",
label: __("Cost Center"),
fieldtype: "MultiSelectList",
options: "Cost Center",
get_data: function (txt) {
return frappe.db.get_link_options("Cost Center", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
},
{
fieldname: "project",
label: __("Project"),
fieldtype: "MultiSelectList",
options: "Project",
get_data: function (txt) {
return frappe.db.get_link_options("Project", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
},
],
};

View File

@@ -174,7 +174,7 @@ frappe.query_reports["General Ledger"] = {
fieldname: "include_dimensions",
label: __("Consider Accounting Dimensions"),
fieldtype: "Check",
default: 1,
default: frappe.boot.sysdefaults.disable_include_dimensions ? 0 : 1,
},
{
fieldname: "disable_opening_balance_calculation",

View File

@@ -74,5 +74,27 @@ frappe.query_reports["Supplier Ledger Summary"] = {
fieldtype: "Data",
hidden: 1,
},
{
fieldname: "cost_center",
label: __("Cost Center"),
fieldtype: "MultiSelectList",
options: "Cost Center",
get_data: function (txt) {
return frappe.db.get_link_options("Cost Center", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
},
{
fieldname: "project",
label: __("Project"),
fieldtype: "MultiSelectList",
options: "Project",
get_data: function (txt) {
return frappe.db.get_link_options("Project", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
},
],
};

View File

@@ -9,6 +9,7 @@ from frappe.utils import cint, flt
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
from erpnext.accounts.utils import get_currency_precision
def execute(filters=None):
@@ -43,6 +44,7 @@ def get_data(filters, show_party_name):
account_filter = get_accounts_with_children(filters.get("account"))
company_currency = frappe.get_cached_value("Company", filters.company, "default_currency")
precision = get_currency_precision()
opening_balances = get_opening_balances(filters, account_filter)
balances_within_period = get_balances_within_period(filters, account_filter)
@@ -65,14 +67,17 @@ def get_data(filters, show_party_name):
# opening
opening_debit, opening_credit = opening_balances.get(party.name, [0, 0])
opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision)
row.update({"opening_debit": opening_debit, "opening_credit": opening_credit})
# within period
debit, credit = balances_within_period.get(party.name, [0, 0])
debit, credit = flt(debit, precision), flt(credit, precision)
row.update({"debit": debit, "credit": credit})
# closing
closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit)
closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision)
row.update({"closing_debit": closing_debit, "closing_credit": closing_credit})
row.update({"currency": company_currency})

View File

@@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.party import get_party_shipping_address
from erpnext.accounts.utils import (
get_currency_precision,
get_future_stock_vouchers,
get_voucherwise_gl_entries,
get_zero_cutoff,
@@ -164,6 +165,21 @@ class TestUtils(unittest.TestCase):
self.assertEqual(get_zero_cutoff("EUR"), 0.005)
self.assertEqual(get_zero_cutoff("BHD"), 0.0005)
def test_get_currency_precision_respects_zero_and_fallback(self):
currency_precision = frappe.db.get_default("currency_precision")
number_format = frappe.db.get_default("number_format")
try:
frappe.db.set_default("number_format", "#,###.##")
frappe.db.set_default("currency_precision", "0")
self.assertEqual(get_currency_precision(), 0)
frappe.db.set_default("currency_precision", "")
self.assertEqual(get_currency_precision(), 2)
finally:
frappe.db.set_default("currency_precision", currency_precision or "")
frappe.db.set_default("number_format", number_format or "#,###.##")
ADDRESS_RECORDS = [
{

View File

@@ -1131,12 +1131,12 @@ def fix_total_debit_credit():
def get_currency_precision():
precision = cint(frappe.db.get_default("currency_precision"))
if not precision:
number_format = frappe.db.get_default("number_format") or "#,###.##"
precision = get_number_format_info(number_format)[2]
currency_precision = frappe.db.get_default("currency_precision")
if currency_precision not in (None, ""):
return cint(currency_precision)
return precision
number_format = frappe.db.get_default("number_format") or "#,###.##"
return get_number_format_info(number_format)[2]
def get_fraction_units(currency: str) -> int:

View File

@@ -81,7 +81,7 @@ def post_depreciation_entries(date=None):
)
try:
make_depreciation_entry(
_make_depreciation_entry(
asset_depr_schedule_name,
date,
sch_start_idx,
@@ -139,7 +139,7 @@ def get_depreciable_asset_depr_schedules_data(date):
def make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date=None):
for row in asset_doc.get("finance_books"):
asset_depr_schedule_name = get_asset_depr_schedule_name(asset_doc.name, "Active", row.finance_book)
make_depreciation_entry(asset_depr_schedule_name, date)
_make_depreciation_entry(asset_depr_schedule_name, date)
def get_acc_frozen_upto():
@@ -193,6 +193,30 @@ def make_depreciation_entry(
credit_and_debit_accounts=None,
depreciation_cost_center_and_depreciation_series=None,
accounting_dimensions=None,
):
asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name)
frappe.has_permission("Asset Depreciation Schedule", "write", asset_depr_schedule_doc, throw=True)
frappe.has_permission("Asset", "write", asset_depr_schedule_doc.asset, throw=True)
return _make_depreciation_entry(
asset_depr_schedule_name,
date,
sch_start_idx,
sch_end_idx,
credit_and_debit_accounts,
depreciation_cost_center_and_depreciation_series,
accounting_dimensions,
)
def _make_depreciation_entry(
asset_depr_schedule_name,
date=None,
sch_start_idx=None,
sch_end_idx=None,
credit_and_debit_accounts=None,
depreciation_cost_center_and_depreciation_series=None,
accounting_dimensions=None,
):
frappe.has_permission("Journal Entry", throw=True)
@@ -395,6 +419,7 @@ def get_comma_separated_links(names, doctype):
@frappe.whitelist()
def scrap_asset(asset_name, scrap_date=None):
frappe.has_permission("Asset", "write", asset_name, throw=True)
asset = frappe.get_doc("Asset", asset_name)
if asset.docstatus != 1:
@@ -472,6 +497,7 @@ def validate_scrap_date(scrap_date, today_date, purchase_date, calculate_depreci
@frappe.whitelist()
def restore_asset(asset_name):
frappe.has_permission("Asset", "write", asset_name, throw=True)
asset = frappe.get_doc("Asset", asset_name)
reverse_depreciation_entry_made_after_disposal(asset, asset.disposal_date)

View File

@@ -166,6 +166,8 @@ class AssetCapitalization(StockController):
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
d.set(k, v)
self.split_valuation_rate_for_grouped_stock_items()
for d in self.asset_items:
args = self.as_dict()
args.update(d.as_dict())
@@ -187,6 +189,30 @@ class AssetCapitalization(StockController):
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
d.set(k, v)
def split_valuation_rate_for_grouped_stock_items(self):
groups = {}
for d in self.stock_items:
if d.item_code and d.warehouse and not (d.serial_no or d.batch_no or d.serial_and_batch_bundle):
groups.setdefault((d.item_code, d.warehouse), []).append(d)
for rows in groups.values():
if len(rows) < 2:
continue
cumulative_qty = 0.0
prev_cumulative_value = 0.0
for d in rows:
cumulative_qty += flt(d.stock_qty)
args = self.get_args_for_incoming_rate(d)
args["qty"] = -1 * cumulative_qty
cumulative_rate = flt(get_incoming_rate(args, raise_error_if_no_rate=False))
cumulative_value = cumulative_rate * cumulative_qty
row_value = cumulative_value - prev_cumulative_value
d.valuation_rate = flt(row_value / d.stock_qty) if flt(d.stock_qty) else 0.0
d.amount = flt(flt(d.stock_qty) * d.valuation_rate, d.precision("amount"))
prev_cumulative_value = cumulative_value
def validate_target_item(self):
target_item = frappe.get_cached_doc("Item", self.target_item_code)
@@ -338,6 +364,8 @@ class AssetCapitalization(StockController):
warehouse_details = get_warehouse_details(args)
d.update(warehouse_details)
self.split_valuation_rate_for_grouped_stock_items()
@frappe.whitelist()
def set_asset_values(self):
for d in self.get("asset_items"):

View File

@@ -10,12 +10,14 @@ from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
from erpnext.assets.doctype.asset.test_asset import (
create_asset,
create_asset_data,
create_fixed_asset_item,
set_depreciation_settings_in_company,
)
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
get_asset_depr_schedule_doc,
)
from erpnext.stock.doctype.item.test_item import create_item
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 (
make_serial_batch_bundle,
)
@@ -340,6 +342,33 @@ class TestAssetCapitalization(unittest.TestCase):
self.assertFalse(get_actual_gle_dict(asset_capitalization.name))
self.assertFalse(get_actual_sle_dict(asset_capitalization.name))
def test_grouped_stock_item_rows_split_fifo_rate(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company"
warehouse = create_warehouse("_Test Warehouse for Grouped FIFO Rows", company=company)
item = create_item(
"_Test Grouped FIFO Rows Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=1
)
target_item = create_fixed_asset_item("_Test Grouped FIFO Rows Target Item")
make_purchase_receipt(item_code=item.item_code, qty=1, rate=100, company=company, warehouse=warehouse)
make_purchase_receipt(item_code=item.item_code, qty=1, rate=200, company=company, warehouse=warehouse)
asset_capitalization = frappe.new_doc("Asset Capitalization")
asset_capitalization.company = company
asset_capitalization.target_item_code = target_item.name
asset_capitalization.append(
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
)
asset_capitalization.append(
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
)
asset_capitalization.insert()
rates = [d.valuation_rate for d in asset_capitalization.stock_items]
self.assertEqual(rates, [100, 200])
def create_asset_capitalization_data():
create_item("Capitalization Target Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0)

View File

@@ -402,6 +402,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List",
@@ -1307,7 +1308,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-07-28 12:20:11.284370",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
from erpnext.accounts.party import get_party_account, get_party_account_currency
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order,
)
@@ -737,13 +738,16 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_order_item")
def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
received_qty = flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
pending_qty = flt(obj.qty) - received_qty
target.qty = flt(obj.qty) if is_unit_price_row(obj) else pending_qty
target.stock_qty = pending_qty * flt(obj.conversion_factor)
target.amount = pending_qty * flt(obj.rate)
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
def select_item(d):
filtered_items = args.get("filtered_children", [])
@@ -775,7 +779,9 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
},
"postprocess": update_item,
"condition": lambda doc: (
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
doc.name not in mapped_qty_by_item
if is_unit_price_row(doc)
else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty)
)
and doc.delivered_by_supplier != 1
and select_item(doc),
@@ -837,9 +843,13 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
)
return query.run(pluck="qty")[0] or 0
mapped_qty_by_item = get_qty_already_mapped(target_doc, "po_detail")
def get_billed_and_mapped_qty(po_item_name):
return flt(get_billed_qty(po_item_name)) + flt(mapped_qty_by_item.get(po_item_name, 0))
def update_item(obj, target, source_parent):
billed_qty = flt(get_billed_qty(obj.name))
target.qty = flt(obj.qty) - billed_qty
target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name)
item = get_item_defaults(target.item_code, source_parent.company)
item_group = get_item_group_defaults(target.item_code, source_parent.company)
@@ -882,6 +892,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
or abs(doc.billed_amt) < abs(doc.amount)
or doc.qty > flt(get_billed_qty(doc.name))
)
and (doc.name not in mapped_qty_by_item or doc.qty > get_billed_and_mapped_qty(doc.name))
and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},

View File

@@ -9,13 +9,18 @@ from frappe import _
from frappe.contacts.doctype.contact.contact import get_full_name
from frappe.core.doctype.communication.email import make
from frappe.desk.form.load import get_attachments
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Order
from frappe.utils import get_url
from frappe.utils.print_format import download_pdf
from frappe.utils.user import get_user_fullname
from erpnext.accounts.party import get_party_account_currency, get_party_details
from erpnext.accounts.party import (
_get_party_details,
get_party_account_currency,
validate_party_frozen_disabled,
)
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.material_request.material_request import set_missing_values
@@ -123,6 +128,8 @@ class RequestforQuotation(BuyingController):
def validate_supplier_list(self):
for d in self.suppliers:
validate_party_frozen_disabled("Supplier", d.supplier)
prevent_rfqs = frappe.db.get_value("Supplier", d.supplier, "prevent_rfqs")
if prevent_rfqs:
standing = frappe.db.get_value("Supplier Scorecard", d.supplier, "status")
@@ -443,7 +450,7 @@ def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier=
def postprocess(source, target_doc):
if for_supplier:
target_doc.supplier = for_supplier
args = get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True)
args = _get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True)
target_doc.currency = args.currency or get_party_account_currency(
"Supplier", for_supplier, source.company
)
@@ -479,36 +486,73 @@ def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier=
# This method is used to make supplier quotation from supplier's portal.
@frappe.whitelist()
def create_supplier_quotation(doc):
def create_supplier_quotation(doc: str | Document | dict):
if isinstance(doc, str):
doc = json.loads(doc)
supplier = doc.get("supplier")
if frappe.session.user not in frappe.get_all(
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
):
if frappe.session.user not in frappe.get_all("Portal User", {"parent": supplier}, pluck="user"):
frappe.throw(_("Not Permitted"), frappe.PermissionError)
try:
sq_doc = frappe.get_doc(
{
"doctype": "Supplier Quotation",
"supplier": doc.get("supplier"),
"terms": doc.get("terms"),
"company": doc.get("company"),
"currency": doc.get("currency")
or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
"buying_price_list": doc.get("buying_price_list")
or frappe.db.get_value("Buying Settings", None, "buying_price_list"),
}
validate_existing_supplier_quotation(supplier, doc.get("items"))
sq_doc = frappe.get_doc(
{
"doctype": "Supplier Quotation",
"supplier": supplier,
"terms": doc.get("terms"),
"company": doc.get("company"),
"currency": doc.get("currency")
or get_party_account_currency("Supplier", supplier, doc.get("company")),
"buying_price_list": doc.get("buying_price_list")
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
}
)
add_items(sq_doc, supplier, doc.get("items"))
sq_doc.flags.ignore_permissions = True
sq_doc.run_method("set_missing_values")
sq_doc.save()
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
return sq_doc.name
def validate_existing_supplier_quotation(supplier, items):
request_for_quotations = {item.get("parent") for item in items if item.get("parent")}
if not request_for_quotations:
return
rfq = frappe.qb.DocType("Request for Quotation")
(
frappe.qb.from_(rfq)
.select(rfq.name)
.where(rfq.name.isin(request_for_quotations))
.orderby(rfq.name)
.for_update()
).run()
sq = frappe.qb.DocType("Supplier Quotation")
sqi = frappe.qb.DocType("Supplier Quotation Item")
existing_quotation = (
frappe.qb.from_(sq)
.inner_join(sqi)
.on(sq.name == sqi.parent)
.select(sq.name, sqi.request_for_quotation)
.where(
(sq.docstatus < 2)
& (sq.supplier == supplier)
& (sqi.request_for_quotation.isin(request_for_quotations))
)
.limit(1)
).run(as_dict=True)
if existing_quotation:
existing_quotation = existing_quotation[0]
frappe.throw(
_("Supplier Quotation {0} already exists against Request for Quotation {1}").format(
frappe.bold(existing_quotation.name),
frappe.bold(existing_quotation.request_for_quotation),
)
)
add_items(sq_doc, doc.get("supplier"), doc.get("items"))
sq_doc.flags.ignore_permissions = True
sq_doc.run_method("set_missing_values")
sq_doc.save()
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
return sq_doc.name
except Exception:
return None
def add_items(sq_doc, supplier, items):

View File

@@ -17,6 +17,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.exceptions import PartyDisabled
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
@@ -57,6 +58,17 @@ class TestRequestforQuotation(FrappeTestCase):
self.assertEqual(rfq.get("suppliers")[0].quote_status, "Received")
self.assertEqual(rfq.get("suppliers")[1].quote_status, "Pending")
def test_rfq_blocked_for_disabled_supplier(self):
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
rfq = make_request_for_quotation(
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
do_not_save=True,
)
self.assertRaises(PartyDisabled, rfq.save)
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
rfq.save()
def test_make_supplier_quotation(self):
rfq = make_request_for_quotation()
@@ -149,6 +161,18 @@ class TestRequestforQuotation(FrappeTestCase):
self.assertEqual(supplier_quotation_doc.get("items")[0].qty, 5)
self.assertEqual(supplier_quotation_doc.get("items")[0].amount, 500)
def test_make_duplicate_supplier_quotation_from_portal(self):
rfq = make_request_for_quotation()
rfq.supplier = rfq.suppliers[0].supplier
supplier_quotation = frappe.get_doc("Supplier Quotation", create_supplier_quotation(rfq))
supplier_quotation.submit()
with self.assertRaisesRegex(frappe.ValidationError, "already exists"):
create_supplier_quotation(rfq)
supplier_quotation.cancel()
self.assertTrue(create_supplier_quotation(rfq))
def test_make_multi_uom_supplier_quotation(self):
item_code = "_Test Multi UOM RFQ Item"
if not frappe.db.exists("Item", item_code):

View File

@@ -40,6 +40,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Supplier",
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
"options": "Supplier",
"reqd": 1
},

View File

@@ -10,6 +10,7 @@ from frappe.contacts.address_and_contact import (
load_address_and_contact,
)
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
from frappe.utils import get_link_to_form
from erpnext.accounts.party import (
get_dashboard_info,
@@ -177,10 +178,15 @@ class Supplier(TransactionBase):
)
if internal_supplier:
internal_supplier_link = get_link_to_form("Supplier", internal_supplier)
frappe.throw(
_("Internal Supplier for company {0} already exists").format(
frappe.bold(self.represents_company)
)
_(
"Internal Supplier {0} already exists for {1}. Disable it to make this Supplier internal."
).format(
internal_supplier_link,
frappe.bold(self.represents_company),
),
title=_("Internal Supplier Already Exists"),
)
def create_primary_contact(self):

View File

@@ -125,12 +125,12 @@ class TestSupplier(FrappeTestCase):
self.assertEqual(supplier.country, "Greece")
def test_party_details_tax_category(self):
from erpnext.accounts.party import get_party_details
from erpnext.accounts.party import _get_party_details
frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing")
# Tax Category without Address
details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
details = _get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
self.assertEqual(details.tax_category, "_Test Tax Category 1")
address = frappe.get_doc(
@@ -147,7 +147,7 @@ class TestSupplier(FrappeTestCase):
).insert()
# Tax Category with Address
details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
details = _get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
self.assertEqual(details.tax_category, "_Test Tax Category 2")
# Rollback

View File

@@ -257,6 +257,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List",
@@ -938,7 +939,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-03-03 17:39:38.459977",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",

View File

@@ -11,6 +11,7 @@ from frappe.utils import flt, getdate, nowdate
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -243,6 +244,8 @@ def make_purchase_order(source_name, target_doc=None, args=None):
if isinstance(args, str):
args = json.loads(args)
mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item")
def set_missing_values(source, target):
target.run_method("set_missing_values")
target.run_method("get_schedule_dates")
@@ -277,7 +280,8 @@ def make_purchase_order(source_name, target_doc=None, args=None):
["sales_order", "sales_order"],
],
"postprocess": update_item,
"condition": select_item,
# no qty tracking between the two, so dedupe on the row reference alone
"condition": lambda d: d.name not in mapped_items and select_item(d),
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -10,6 +10,26 @@ frappe.query_reports["Purchase Analytics"] = {
options: ["Supplier Group", "Supplier", "Item Group", "Item"],
default: "Supplier",
reqd: 1,
on_change: function () {
const entity_filter = frappe.query_report.get_filter("entity");
if (entity_filter) {
entity_filter.df.label = __(frappe.query_report.get_filter_value("tree_type"));
entity_filter.set_value([]);
entity_filter.refresh();
}
frappe.query_report.refresh();
},
},
{
fieldname: "entity",
label: __("Entity"),
fieldtype: "MultiSelectList",
get_data: function (txt) {
const tree_type = frappe.query_report.get_filter_value("tree_type");
if (!tree_type || tree_type === "Order Type") return [];
return frappe.db.get_link_options(tree_type, txt);
},
depends_on: "eval:doc.tree_type != 'Order Type'",
},
{
fieldname: "doc_type",
@@ -65,6 +85,19 @@ frappe.query_reports["Purchase Analytics"] = {
default: "Monthly",
reqd: 1,
},
{
fieldname: "curves",
label: __("Curves"),
fieldtype: "Select",
options: [
{ value: "select", label: __("Select") },
{ value: "all", label: __("All") },
{ value: "non-zeros", label: __("Non-Zeros") },
{ value: "total", label: __("Total Only") },
],
default: "select",
reqd: 1,
},
],
get_datatable_options(options) {
return Object.assign(options, {

View File

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

View File

@@ -85,6 +85,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
],
default: __("Categorize by Supplier"),
},
{
fieldname: "status",
label: __("Status"),
fieldtype: "Select",
options: [
{ label: "", value: "" },
{ label: __("Draft"), value: "Draft" },
{ label: __("Submitted"), value: "Submitted" },
],
default: "Submitted",
},
{
fieldtype: "Check",
label: __("Include Expired"),

View File

@@ -58,13 +58,20 @@ def get_data(filters):
)
.where(
(sq_item.parent == sq.name)
& (sq_item.docstatus < 2)
& (sq.company == filters.get("company"))
& (sq.transaction_date.between(filters.get("from_date"), filters.get("to_date")))
)
.orderby(sq.transaction_date, sq_item.item_code)
)
# blank -> Draft + Submitted, else filter to the chosen docstatus
if filters.get("status") == "Draft":
query = query.where(sq_item.docstatus == 0)
elif filters.get("status") == "Submitted":
query = query.where(sq_item.docstatus == 1)
else:
query = query.where(sq_item.docstatus < 2)
if filters.get("item_code"):
query = query.where(sq_item.item_code == filters.get("item_code"))

View File

@@ -67,6 +67,7 @@ from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.item.item import get_uom_conv_factor
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
_get_item_tax_template,
_get_item_tax_template_from_item_group,
get_bin_details,
@@ -719,6 +720,8 @@ class AccountsController(TransactionBase):
self.validate_non_invoice_documents_schedule()
def before_print(self, settings=None):
self.set_missing_terms()
if self.doctype in [
"Purchase Order",
"Sales Order",
@@ -742,6 +745,16 @@ class AccountsController(TransactionBase):
set_print_templates_for_item_table(self, settings)
set_print_templates_for_taxes(self, settings)
def set_missing_terms(self):
if not self.get("tc_name") or self.get("terms"):
return
from erpnext.setup.doctype.terms_and_conditions.terms_and_conditions import (
get_terms_and_conditions,
)
self.terms = get_terms_and_conditions(self.tc_name, self.as_dict())
def calculate_paid_amount(self):
if hasattr(self, "is_pos") or hasattr(self, "is_paid"):
is_paid = self.get("is_pos") or self.get("is_paid")
@@ -1254,6 +1267,11 @@ class AccountsController(TransactionBase):
if self.get("taxes") or self.get("is_pos"):
return
# set by the Opening Invoice Creation Tool, where the outstanding amount
# entered against a party is already inclusive of tax
if self.flags.dont_auto_add_taxes:
return
if frappe.get_single_value(
"Accounts Settings", "add_taxes_from_taxes_and_charges_template"
) and hasattr(self, "taxes_and_charges"):
@@ -1282,7 +1300,10 @@ class AccountsController(TransactionBase):
if isinstance(item_tax_rate, str):
item_tax_rate = parse_json(item_tax_rate)
for account_head, _rate in item_tax_rate.items():
for account_head, rate in item_tax_rate.items():
if rate == NOT_APPLICABLE_TAX:
continue
row = self.get_tax_row(account_head)
if not row:
@@ -3709,8 +3730,11 @@ def add_taxes_from_tax_template(child_item, parent_doc, db_insert=True):
if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template:
tax_map = json.loads(child_item.get("item_tax_rate"))
for tax_type in tax_map:
tax_rate = flt(tax_map[tax_type])
for tax_type, tax_rate in tax_map.items():
if tax_rate == NOT_APPLICABLE_TAX:
continue
tax_rate = flt(tax_rate)
taxes = parent_doc.get("taxes") or []
# add new row for tax head only if missing
found = any(tax.account_head == tax_type for tax in taxes)

View File

@@ -11,7 +11,7 @@ from frappe.utils.data import nowtime
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.party import get_party_details
from erpnext.accounts.party import _get_party_details
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
@@ -165,7 +165,7 @@ class BuyingController(SubcontractingController):
# set contact and address details for supplier, if they are not mentioned
if getattr(self, "supplier", None):
self.update_if_missing(
get_party_details(
_get_party_details(
self.supplier,
party_type="Supplier",
doctype=self.doctype,
@@ -755,7 +755,7 @@ class BuyingController(SubcontractingController):
if po and po_item_rows:
po_obj = frappe.get_doc("Purchase Order", po)
if po_obj.status in ["Closed", "Cancelled"]:
if po_obj.status == "Cancelled" or (po_obj.status == "Closed" and not self.get("is_return")):
frappe.throw(
_("{0} {1} is cancelled or closed").format(_("Purchase Order"), po),
frappe.InvalidStatusError,

View File

@@ -0,0 +1,25 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt
def get_qty_already_mapped(target_doc, ref_field: str, qty_field: str = "qty") -> frappe._dict:
"""Return a map: {source row name: qty} of rows already mapped into the target document.
"Get Items From" passes the in-progress (unsaved) document back as `target_doc`. Its rows
are invisible to the pending-qty queries in the mappers, which only count submitted
documents -- so without this, selecting the same source document twice maps every row
again. Rows are keyed by `ref_field` (dn_detail, so_detail, ...), and a row is present in
the map even when its qty is 0, so mappers without qty tracking can dedupe on presence.
"""
if isinstance(target_doc, str):
target_doc = frappe.parse_json(target_doc)
qty_map = frappe._dict()
for row in (target_doc and target_doc.get("items")) or []:
if ref := row.get(ref_field):
qty_map[ref] = qty_map.get(ref, 0) + flt(row.get(qty_field))
return qty_map

View File

@@ -213,7 +213,7 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
else 0
)
if column == "stock_qty" and not args.get("return_qty_from_rejected_warehouse"):
if column in ("stock_qty", "qty") and not args.get("return_qty_from_rejected_warehouse"):
reference_qty = ref.get(column)
current_stock_qty = args.get(column)
elif args.get("return_qty_from_rejected_warehouse"):

View File

@@ -560,7 +560,8 @@ class SellingController(StockController):
reset_incoming_rate()
if (
not d.incoming_rate
(not d.incoming_rate or self.is_new())
and not is_standalone
or self.is_internal_transfer()
or (get_valuation_method(d.item_code) == "Moving Average" and self.get("is_return"))
):

View File

@@ -19,7 +19,11 @@ from erpnext.controllers.accounts_controller import (
validate_inclusive_tax,
validate_taxes_and_charges,
)
from erpnext.stock.get_item_details import _get_item_tax_template, get_item_tax_map
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
_get_item_tax_template,
get_item_tax_map,
)
from erpnext.utilities.regional import temporary_flag
@@ -275,6 +279,7 @@ class calculate_taxes_and_totals:
tax.item_wise_tax_detail = {}
tax_fields = [
"net_amount",
"total",
"tax_amount_after_discount_amount",
"tax_amount_for_current_item",
@@ -298,33 +303,32 @@ class calculate_taxes_and_totals:
for item in self.doc.items:
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
cumulated_tax_fraction = 0
total_inclusive_tax_amount_per_qty = 0
total_tax_slope = 0
total_tax_intercept = 0
for i, tax in enumerate(self.doc.get("taxes")):
(
tax.tax_fraction_for_current_item,
inclusive_tax_amount_per_qty,
) = self.get_current_tax_fraction(tax, item_tax_map)
tax_intercept_per_qty,
) = self.get_current_tax_fraction(tax, item_tax_map, item)
tax.inclusive_amount_per_qty = tax_intercept_per_qty
if i == 0:
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item
tax.grand_total_amount_per_qty = tax_intercept_per_qty
else:
prev = self.doc.get("taxes")[i - 1]
tax.grand_total_fraction_for_current_item = (
self.doc.get("taxes")[i - 1].grand_total_fraction_for_current_item
+ tax.tax_fraction_for_current_item
prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item
)
tax.grand_total_amount_per_qty = prev.grand_total_amount_per_qty + tax_intercept_per_qty
cumulated_tax_fraction += tax.tax_fraction_for_current_item
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty)
total_tax_slope += tax.tax_fraction_for_current_item
total_tax_intercept += tax_intercept_per_qty * flt(item.qty)
if (
not self.discount_amount_applied
and item.qty
and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty)
):
amount = flt(item.amount) - total_inclusive_tax_amount_per_qty
if not self.discount_amount_applied and item.qty and (total_tax_slope or total_tax_intercept):
amount = flt(item.amount) - total_tax_intercept
item.net_amount = flt(amount / (1 + cumulated_tax_fraction), item.precision("net_amount"))
item.net_amount = flt(amount / (1 + total_tax_slope), item.precision("net_amount"))
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
item.discount_percentage = flt(
item.discount_percentage, item.precision("discount_percentage")
@@ -335,44 +339,57 @@ class calculate_taxes_and_totals:
def _load_item_tax_rate(self, item_tax_rate):
return json.loads(item_tax_rate) if item_tax_rate else {}
def get_current_tax_fraction(self, tax, item_tax_map):
def get_current_tax_fraction(self, tax, item_tax_map, item):
"""
Get tax fraction for calculating tax exclusive amount
from tax inclusive amount
tax = slope * net + intercept.
Returns (slope, intercept_per_qty)
"""
current_tax_fraction = 0
inclusive_tax_amount_per_qty = 0
tax_slope = 0
tax_intercept = 0
if cint(tax.included_in_print_rate):
tax_rate = self._get_tax_rate(tax, item_tax_map)
if tax_rate == NOT_APPLICABLE_TAX:
return tax_slope, tax_intercept
if tax.charge_type == "On Net Total":
current_tax_fraction = tax_rate / 100.0
tax_slope = tax_rate / 100.0
elif tax.charge_type == "On Previous Row Amount":
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
cint(tax.row_id) - 1
].tax_fraction_for_current_item
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "inclusive_amount_per_qty", 0))
elif tax.charge_type == "On Previous Row Total":
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
cint(tax.row_id) - 1
].grand_total_fraction_for_current_item
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "grand_total_amount_per_qty", 0))
elif tax.charge_type == "On Item Quantity":
inclusive_tax_amount_per_qty = flt(tax_rate)
tax_intercept = flt(tax_rate)
else:
# Custom charge_type: the rate applies to a resolved (fixed) base,
# e.g. a tax on MRP included in the printed price.
qty = flt(item.qty) or 1
base = self.get_item_taxable_base(item, tax)
tax_intercept = (tax_rate / 100.0) * base / qty
if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct":
current_tax_fraction *= -1.0
inclusive_tax_amount_per_qty *= -1.0
tax_slope *= -1.0
tax_intercept *= -1.0
return current_tax_fraction, inclusive_tax_amount_per_qty
return tax_slope, tax_intercept
def _get_tax_rate(self, tax, item_tax_map):
if tax.account_head in item_tax_map:
return flt(item_tax_map.get(tax.account_head), self.doc.precision("rate", tax))
else:
return tax.rate
rate = item_tax_map[tax.account_head]
if rate == NOT_APPLICABLE_TAX:
return NOT_APPLICABLE_TAX
return flt(rate, self.doc.precision("rate", tax))
return tax.rate
def calculate_net_total(self):
self.doc.total_qty = (
@@ -420,9 +437,12 @@ class calculate_taxes_and_totals:
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
for i, tax in enumerate(doc.taxes):
# tax_amount represents the amount of tax for the current step
current_tax_amount = self.get_current_tax_amount(item, tax, item_tax_map)
current_net_amount, current_tax_amount = self.get_current_tax_and_net_amount(
item, tax, item_tax_map
)
if frappe.flags.round_row_wise_tax:
current_tax_amount = flt(current_tax_amount, tax.precision("tax_amount"))
current_net_amount = flt(current_net_amount, tax.precision("net_amount"))
# Adjust divisional loss to the last item
if tax.charge_type == "Actual":
@@ -430,6 +450,10 @@ class calculate_taxes_and_totals:
if n == len(self._items) - 1:
current_tax_amount += actual_tax_dict[tax.idx]
# net_amount is the taxable basis, it feeds no total and is always
# accumulated, unlike tax_amount which is kept from the first pass
tax.net_amount += current_net_amount
# accumulate tax amount into tax.tax_amount
if tax.charge_type != "Actual" and not (
self.discount_amount_applied and self.doc.apply_discount_on == "Grand Total"
@@ -480,7 +504,9 @@ class calculate_taxes_and_totals:
for i, tax in enumerate(doc.taxes):
self.round_off_totals(tax)
self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"])
self._set_in_company_currency(
tax, ["tax_amount", "tax_amount_after_discount_amount", "net_amount"]
)
self.round_off_base_values(tax)
self.set_cumulative_total(i, tax)
@@ -511,8 +537,17 @@ class calculate_taxes_and_totals:
tax.total = flt(self.doc.get("taxes")[row_idx - 1].total + tax_amount, tax.precision("total"))
def get_current_tax_amount(self, item, tax, item_tax_map):
# kept for backwards compatibility with callers outside this module
_, current_tax_amount = self.get_current_tax_and_net_amount(item, tax, item_tax_map)
return current_tax_amount
def get_current_tax_and_net_amount(self, item, tax, item_tax_map):
tax_rate = self._get_tax_rate(tax, item_tax_map)
current_tax_amount = 0.0
current_net_amount = 0.0
if tax_rate == NOT_APPLICABLE_TAX:
return current_net_amount, current_tax_amount
if tax.charge_type == "Actual":
# distribute the tax amount proportionally to each item row
@@ -522,29 +557,63 @@ class calculate_taxes_and_totals:
if not item.get("apply_tds") or not self.doc.tax_withholding_net_total:
current_tax_amount = 0.0
else:
current_tax_amount = item.net_amount * actual / self.doc.tax_withholding_net_total
current_net_amount = item.net_amount
current_tax_amount = current_net_amount * actual / self.doc.tax_withholding_net_total
else:
current_net_amount = item.net_amount
current_tax_amount = (
item.net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0
current_net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0
)
elif tax.charge_type == "On Net Total":
current_net_amount = item.net_amount
current_tax_amount = (tax_rate / 100.0) * item.net_amount
elif tax.charge_type == "On Previous Row Amount":
current_tax_amount = (tax_rate / 100.0) * self.doc.get("taxes")[
cint(tax.row_id) - 1
].tax_amount_for_current_item
current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].tax_amount_for_current_item
current_tax_amount = (tax_rate / 100.0) * current_net_amount
elif tax.charge_type == "On Previous Row Total":
current_tax_amount = (tax_rate / 100.0) * self.doc.get("taxes")[
cint(tax.row_id) - 1
].grand_total_for_current_item
current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].grand_total_for_current_item
current_tax_amount = (tax_rate / 100.0) * current_net_amount
elif tax.charge_type == "On Item Quantity":
# don't sum current net amount: net_amount field is currency-denominated
current_tax_amount = tax_rate * item.qty
else:
# Custom charge_type: rate applies to the resolver-provided base.
current_tax_amount = (tax_rate / 100.0) * self.get_item_taxable_base(item, tax)
if not (self.doc.get("is_consolidated") or tax.get("dont_recompute_tax")):
self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount)
return current_tax_amount
return current_net_amount, current_tax_amount
def get_item_taxable_base(self, item, tax):
"""Per-item base a custom charge_type's rate is applied to.
Override the base (gross, MRP, net of other taxes, …) via the
`erpnext_taxable_base_resolvers` hook
Register a resolver in `hooks.py`, keyed by charge_type:
erpnext_taxable_base_resolvers = {"On Gross Amount": "my_app.taxes.gross_base"}
It receives (calc, item, tax) — calc is this instance, calc.doc the parent —
and returns the base (flt-coerced by the caller):
def gross_base(calc, item, tax):
return item.custom_field_mrp * item.qty
A resolver may stamp transient attributes on `item`; it can be called more than once
per item, so such stamping must be idempotent.
"""
resolvers = frappe.get_hooks("erpnext_taxable_base_resolvers") or {}
path = resolvers.get(tax.charge_type)
if path:
method = path[-1] if isinstance(path, list | tuple) else path
return flt(frappe.get_attr(method)(self, item, tax))
# fallback
return flt(item.net_amount)
def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount):
# store tax breakup for each item
@@ -788,8 +857,9 @@ class calculate_taxes_and_totals:
item.net_amount = flt(
item.net_amount + rounding_difference, item.precision("net_amount")
)
# net_amount went up by rounding_difference, so its discount share goes down
item.distributed_discount_amount = flt(
distributed_amount + rounding_difference,
distributed_amount - rounding_difference,
item.precision("distributed_discount_amount"),
)
net_total += rounding_difference

View File

@@ -1,4 +1,4 @@
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
@@ -60,6 +60,30 @@ class TestTaxesAndTotals(AccountsTestMixin, FrappeTestCase):
self.assertAlmostEqual(so.net_total, 1272.73, places=2)
self.assertEqual(so.grand_total, 1400)
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_distributed_discount_amount_with_rounding_adjustment(self):
so = make_sales_order(do_not_save=1)
so.apply_discount_on = "Net Total"
so.discount_amount = 10
so.items[0].qty = 1
so.items[0].rate = 100
so.append("items", so.items[0].as_dict())
so.append("items", so.items[0].as_dict())
so.save()
calculate_taxes_and_totals(so)
# the rounding adjustment lands on the second line
self.assertAlmostEqual(so.items[1].net_amount, 96.66, places=2)
self.assertAlmostEqual(so.items[1].distributed_discount_amount, 3.34, places=2)
for item in so.items:
self.assertAlmostEqual(item.amount - item.distributed_discount_amount, item.net_amount, places=2)
self.assertAlmostEqual(
sum(i.distributed_discount_amount for i in so.items), so.discount_amount, places=2
)
self.assertEqual(so.net_total, 290)
def test_100_percent_discount_with_inclusive_tax(self):
"""Test that 100% discount with inclusive taxes results in zero net_total"""
so = make_sales_order(do_not_save=1)

View File

@@ -28,6 +28,96 @@ class TestMapper(unittest.TestCase):
src_items = item_list_1 + item_list_2 + item_list_3
self.assertEqual(set(d for d in src_items), set(d.item_code for d in updated_so.items))
def test_get_items_from_is_idempotent(self):
"""Selecting the same source document twice must not duplicate rows in the target.
"Get Items From" hands the in-progress document back to the mapper as `target_doc`.
Its rows are unsaved, so the mappers' pending-qty queries (submitted documents only)
cannot see them -- every mapper has to discount them explicitly.
"""
for label, make_source, method in self.idempotency_cases():
with self.subTest(label):
source = make_source()
target = frappe.get_attr(method)(source.name)
mapped_rows = len(target.items)
self.assertTrue(mapped_rows, f"{label}: nothing was mapped")
target = frappe.get_attr(method)(source.name, target)
self.assertEqual(len(target.items), mapped_rows, f"{label}: rows were duplicated")
def idempotency_cases(self):
"""(label, source factory, mapper method) for every "Get Items From" button.
Quotation -> Sales Invoice is absent: Sales Invoice Item keeps no reference to the
Quotation row, so there is nothing to deduplicate on.
"""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.doctype.supplier_quotation.test_supplier_quotation import (
test_records as supplier_quotation_records,
)
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
def make_supplier_quotation():
return frappe.copy_doc(supplier_quotation_records[0]).submit()
return [
(
"Quotation -> Sales Order",
lambda: make_quotation(),
"erpnext.selling.doctype.quotation.quotation.make_sales_order",
),
(
"Sales Order -> Sales Invoice",
lambda: make_sales_order(),
"erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
),
(
"Sales Order -> Delivery Note",
lambda: make_sales_order(),
"erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
),
(
"Delivery Note -> Sales Invoice",
lambda: create_delivery_note(),
"erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
),
(
"Material Request -> Purchase Order",
lambda: make_material_request(),
"erpnext.stock.doctype.material_request.material_request.make_purchase_order",
),
(
"Supplier Quotation -> Purchase Order",
make_supplier_quotation,
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order",
),
(
"Purchase Order -> Purchase Receipt",
lambda: create_purchase_order(),
"erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt",
),
(
"Purchase Order -> Purchase Invoice",
lambda: create_purchase_order(),
"erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
),
(
"Purchase Receipt -> Purchase Invoice",
lambda: make_purchase_receipt(),
"erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
),
(
"Purchase Invoice -> Purchase Receipt",
lambda: make_purchase_invoice(),
"erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt",
),
]
def make_quotation(self, item_list, customer):
qtn = frappe.get_doc(
{

View File

@@ -87,3 +87,35 @@ class TestSalesAndPurchaseReturn(FrappeTestCase):
return_si.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_si.save)
def test_sales_invoice_partial_return_with_different_stock_uom(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.item.test_item import make_item
item_properties = {"is_stock_item": 1, "stock_uom": "Kg"}
if frappe.get_meta("Item").has_field("gst_hsn_code") and frappe.db.exists("GST HSN Code", "010121"):
item_properties["gst_hsn_code"] = "010121"
item = make_item(
"_Test SI Return Different Stock UOM",
item_properties,
uoms=[{"uom": "Nos", "conversion_factor": 0.013888889}],
)
si = create_sales_invoice(item_code=item.name, qty=48, do_not_save=True)
si.items[0].uom = "Nos"
si.items[0].stock_uom = "Kg"
si.items[0].conversion_factor = 0.013888889
si.save().submit()
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
first_return = make_return_doc(si.doctype, si.name)
first_return.items[0].qty = -24
first_return.save().submit()
self.addCleanup(self._cancel_and_delete, "Sales Invoice", first_return.name)
second_return = make_return_doc(si.doctype, si.name)
self.assertEqual(second_return.items[0].qty, -24)
second_return.save().submit()
self.addCleanup(self._cancel_and_delete, "Sales Invoice", second_return.name)

View File

@@ -1,12 +1,24 @@
from unittest import mock
from unittest.mock import patch
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import flt
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
def resolve_on_gross(calc, item, tax):
# base = gross printed line amount
return flt(item.amount)
def resolve_on_mrp(calc, item, tax):
# base = MRP, not net
return flt(item.price_list_rate) * flt(item.qty)
class TestTaxesAndTotals(FrappeTestCase):
def test_regional_round_off_accounts(self):
"""
@@ -30,6 +42,93 @@ class TestTaxesAndTotals(FrappeTestCase):
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
def test_exclusive_custom_charge_on_resolved_base(self):
"""Added (exclusive) custom charge_type whose base is resolved by the
`erpnext_taxable_base_resolvers` hook. IPI 10% on the gross product value 1000
-> tax 100, net 1000, grand 1100."""
so = make_sales_order(do_not_save=True)
so.items = []
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 1000,
"price_list_rate": 1000,
"warehouse": "_Test Warehouse - _TC",
},
)
so.set("taxes", [])
so.append(
"taxes",
{
"charge_type": "On Gross Value",
"account_head": "_Test Account Excise Duty - _TC",
"description": "IPI 10% on gross product value",
"rate": 10,
"cost_center": "_Test Cost Center - _TC",
},
)
real_get_hooks = frappe.get_hooks
def fake_get_hooks(hook=None, *args, **kwargs):
if hook == "erpnext_taxable_base_resolvers":
return {
"On Gross Value": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_gross"]
}
return real_get_hooks(hook, *args, **kwargs)
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
calculate_taxes_and_totals(so)
self.assertEqual(so.net_total, 1000.0)
self.assertEqual(so.taxes[0].tax_amount, 100.0)
self.assertEqual(so.grand_total, 1100.0)
def test_inclusive_custom_charge_on_resolved_base(self):
"""Inclusive custom charge on a resolved base backs out non-compounding
(tax = rate x resolved base) — a resolved base is fixed, so it never
compounds. MRP 1200, printed 1000, rate 10%: tax 120, net 880."""
so = make_sales_order(do_not_save=True)
so.items = []
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 1000,
"price_list_rate": 1200,
"warehouse": "_Test Warehouse - _TC",
},
)
so.set("taxes", [])
so.append(
"taxes",
{
"charge_type": "On MRP",
"account_head": "_Test Account VAT - _TC",
"description": "Tax 10% on MRP, inclusive",
"rate": 10,
"included_in_print_rate": 1,
"cost_center": "_Test Cost Center - _TC",
},
)
real_get_hooks = frappe.get_hooks
def fake_get_hooks(hook=None, *args, **kwargs):
if hook == "erpnext_taxable_base_resolvers":
return {"On MRP": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_mrp"]}
return real_get_hooks(hook, *args, **kwargs)
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
calculate_taxes_and_totals(so)
self.assertEqual(so.taxes[0].tax_amount, 120.0)
self.assertEqual(so.net_total, 880.0)
self.assertEqual(so.grand_total, 1000.0)
def test_disabling_rounded_total_resets_base_fields(self):
"""Disabling rounded total should also clear base rounded values."""
so = make_sales_order(do_not_save=True)
@@ -59,3 +158,141 @@ class TestTaxesAndTotals(FrappeTestCase):
self.assertEqual(so.rounding_adjustment, 0)
self.assertEqual(so.base_rounded_total, 0)
self.assertEqual(so.base_rounding_adjustment, 0)
def test_tax_net_amount_with_not_applicable_item_tax(self):
"""Each tax row records only the net of the items it actually applies to.
Two items of 100 each, one per template. Template A applies VAT 7 and
marks VAT 19 not applicable, template B does the reverse. Both tax rows
must report a net_amount of 100, not the full net total of 200.
"""
vat_7 = "_Test Account VAT - _TC"
vat_19 = "_Test Account Service Tax - _TC"
templates = {}
for title, rows in {
"_Test NA Template A": [(vat_7, 7, 0), (vat_19, 0, 1)],
"_Test NA Template B": [(vat_7, 0, 1), (vat_19, 19, 0)],
}.items():
doc = frappe.new_doc("Item Tax Template")
doc.title = title
doc.company = "_Test Company"
for tax_type, tax_rate, not_applicable in rows:
doc.append(
"taxes",
{"tax_type": tax_type, "tax_rate": tax_rate, "not_applicable": not_applicable},
)
templates[title] = doc.insert().name
so = make_sales_order(do_not_save=True)
so.items = []
for title in templates:
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 100,
"warehouse": "_Test Warehouse - _TC",
"item_tax_template": templates[title],
},
)
so.set("taxes", [])
for account_head in (vat_7, vat_19):
so.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": account_head,
"description": account_head,
"rate": 0,
"cost_center": "_Test Cost Center - _TC",
},
)
so.save()
self.assertEqual(so.net_total, 200.0)
self.assertEqual(so.taxes[0].net_amount, 100.0)
self.assertEqual(so.taxes[0].tax_amount, 7.0)
self.assertEqual(so.taxes[1].net_amount, 100.0)
self.assertEqual(so.taxes[1].tax_amount, 19.0)
def test_inclusive_tax_with_not_applicable_item_tax(self):
"""An inclusive tax row meeting an item that marks it not applicable must
contribute no fraction, instead of raising in get_current_tax_fraction."""
vat_19 = "_Test Account Service Tax - _TC"
template = frappe.new_doc("Item Tax Template")
template.title = "_Test NA Template Inclusive"
template.company = "_Test Company"
template.append("taxes", {"tax_type": vat_19, "tax_rate": 0, "not_applicable": 1})
template.insert()
so = make_sales_order(do_not_save=True)
so.items = []
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 119,
"warehouse": "_Test Warehouse - _TC",
"item_tax_template": template.name,
},
)
so.set("taxes", [])
so.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": vat_19,
"description": vat_19,
"rate": 19,
"included_in_print_rate": 1,
"cost_center": "_Test Cost Center - _TC",
},
)
so.save()
# the tax does not apply, so nothing is backed out of the printed rate
self.assertEqual(so.net_total, 119.0)
self.assertEqual(so.taxes[0].tax_amount, 0.0)
self.assertEqual(so.taxes[0].net_amount, 0.0)
self.assertEqual(so.grand_total, 119.0)
def test_tax_net_amount_survives_grand_total_discount(self):
"""A discount on Grand Total re-runs the calculation with
discount_amount_applied set. net_amount is reset on that second pass, so
it has to be accumulated there too instead of being left at zero."""
so = make_sales_order(do_not_save=True)
so.items = []
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 10,
"rate": 100,
"warehouse": "_Test Warehouse - _TC",
},
)
so.set("taxes", [])
so.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account VAT - _TC",
"description": "VAT",
"rate": 19,
"cost_center": "_Test Cost Center - _TC",
},
)
so.apply_discount_on = "Grand Total"
so.discount_amount = 100
calculate_taxes_and_totals(so)
self.assertEqual(so.taxes[0].net_amount, so.net_total)
self.assertEqual(so.grand_total, 1090.0)

View File

@@ -39,6 +39,7 @@ def get_contract_template(template_name, doc):
doc = json.loads(doc)
contract_template = frappe.get_doc("Contract Template", template_name)
contract_template.check_permission()
contract_terms = None
if contract_template.contract_terms:

View File

@@ -69,6 +69,13 @@ class CRMSettings(Document):
self.allowed_users = []
def custom_fields_for_frappe_crm_data_sync(self):
custom_fields = self.get_frappe_crm_custom_fields()
if self.enable_frappe_crm_data_synchronization:
create_custom_fields(custom_fields, ignore_validate=True)
@staticmethod
def get_frappe_crm_custom_fields():
custom_fields = {
"Quotation": [
{
@@ -88,4 +95,4 @@ class CRMSettings(Document):
],
}
create_custom_fields(custom_fields, ignore_validate=True)
return custom_fields

View File

@@ -13,6 +13,7 @@ from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from frappe.utils import flt, get_fullname
from erpnext.accounts.party import validate_party_frozen_disabled
from erpnext.crm.utils import (
CRMNote,
copy_comments,
@@ -131,6 +132,7 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_item_details()
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.validate_party()
self.map_fields()
self.validate_qty()
self.set_exchange_rate()
@@ -346,6 +348,10 @@ class Opportunity(TransactionBase, CRMNote):
return False
return True
def validate_party(self) -> None:
if self.opportunity_from == "Customer":
validate_party_frozen_disabled("Customer", self.party_name)
def validate_cust_name(self):
if self.party_name:
if self.opportunity_from == "Customer":

View File

@@ -10,6 +10,7 @@ from erpnext.crm.doctype.lead.lead import make_customer
from erpnext.crm.doctype.lead.test_lead import make_lead
from erpnext.crm.doctype.opportunity.opportunity import make_quotation
from erpnext.crm.utils import get_linked_communication_list
from erpnext.exceptions import PartyDisabled
test_records = frappe.get_test_records("Opportunity")
@@ -52,6 +53,23 @@ class TestOpportunity(unittest.TestCase):
opportunity_doc = make_opportunity(with_items=1, rate=1100, qty=2)
self.assertEqual(opportunity_doc.total, 2200)
def test_disabled_customer_not_allowed(self):
frappe.db.set_value("Customer", "_Test Customer", "disabled", 1)
self.assertRaises(PartyDisabled, make_opportunity, with_items=0)
frappe.db.set_value("Customer", "_Test Customer", "disabled", 0)
make_opportunity(with_items=0)
def test_disabled_lead_not_blocked(self):
# Lead.disabled isn't enforced anywhere else (e.g. the Lead picker query only
# excludes Converted leads), so it shouldn't block Opportunity creation either.
lead_doc = make_lead()
frappe.db.set_value("Lead", lead_doc.name, "disabled", 1)
opp_doc = make_opportunity(opportunity_from="Lead", lead=lead_doc.name)
self.assertEqual(opp_doc.party_name, lead_doc.name)
def test_carry_forward_of_email_and_comments(self):
frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1)
lead_doc = make_lead()

View File

@@ -1,53 +1,6 @@
import frappe
@frappe.whitelist()
def get_last_interaction(contact=None, lead=None):
if not contact and not lead:
return
last_communication = None
last_issue = None
if contact:
query_condition = ""
values = []
contact = frappe.get_doc("Contact", contact)
for link in contact.links:
if link.link_doctype == "Customer":
last_issue = get_last_issue_from_customer(link.link_name)
query_condition += "(`reference_doctype`=%s AND `reference_name`=%s) OR"
values += [link.link_doctype, link.link_name]
if query_condition:
# remove extra appended 'OR'
query_condition = query_condition[:-2]
last_communication = frappe.db.sql(
f"""
SELECT `name`, `content`
FROM `tabCommunication`
WHERE `sent_or_received`='Received'
AND ({query_condition})
ORDER BY `modified`
LIMIT 1
""",
values,
as_dict=1,
) # nosec
if lead:
last_communication = frappe.get_all(
"Communication",
filters={"reference_doctype": "Lead", "reference_name": lead, "sent_or_received": "Received"},
fields=["name", "content"],
order_by="`creation` DESC",
limit=1,
)
last_communication = last_communication[0] if last_communication else None
return {"last_communication": last_communication, "last_issue": last_issue}
def get_last_issue_from_customer(customer_name):
issues = frappe.get_all(
"Issue",

View File

@@ -227,7 +227,10 @@ class CRMNote(Document):
notify_mentions(self.doctype, self.name, note)
@frappe.whitelist()
def edit_note(self, note, row_id):
def edit_note(self, note: str, row_id: str):
# db_update() skips the write check that save() does in add_note/delete_note
self.check_permission("write")
for d in self.notes:
if cstr(d.name) == row_id:
d.note = note

View File

@@ -597,16 +597,16 @@ regional_overrides = {
"erpnext.controllers.accounts_controller.validate_regional": "erpnext.regional.italy.utils.sales_invoice_validate",
},
}
user_privacy_documents = [
user_data_fields = [
{
"doctype": "Lead",
"match_field": "email_id",
"personal_fields": ["phone", "mobile_no", "fax", "website", "lead_name"],
"filter_by": "email_id",
"redact_fields": ["phone", "mobile_no", "fax", "website", "lead_name"],
},
{
"doctype": "Opportunity",
"match_field": "contact_email",
"personal_fields": ["contact_mobile", "contact_display", "customer_name"],
"filter_by": "contact_email",
"redact_fields": ["contact_mobile", "contact_display", "customer_name"],
},
]

View File

@@ -173,7 +173,9 @@ frappe.ui.form.on("BOM", {
frm.set_intro(
__("This is a Template BOM and will be used to make the work order for {0} of the item {1}", [
`<a class="variants-intro">variants</a>`,
`<a href="/app/item/${frm.doc.item}">${frm.doc.item}</a>`,
`<a href="${frappe.utils.get_form_link("Item", frm.doc.item)}">${frappe.utils.escape_html(
frm.doc.item
)}</a>`,
]),
true
);

View File

@@ -17,11 +17,11 @@
<hr style="margin: 15px -15px;">
<p>
{% if data.value && data.value != "BOM" %}
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/bom/{{ data.value }}">
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/bom/{{ frappe.utils.escape_html(data.value) }}">
{{ __("Open BOM {0}", [data.value.bold()]) }}</a>
{% endif %}
{% if data.item_code %}
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/item/{{ data.item_code }}">
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/item/{{ frappe.utils.escape_html(data.item_code) }}">
{{ __("Open Item {0}", [data.item_code.bold()]) }}</a>
{% endif %}
</p>

View File

@@ -206,6 +206,43 @@ class TestJobCard(FrappeTestCase):
# transfer was made for 2 fg qty in first transfer Stock Entry
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
def test_material_request_stock_entry_uses_job_card_coverage(self):
from erpnext.stock.doctype.material_request.material_request import make_stock_entry
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
mr = make_material_request(job_card.name)
mr.schedule_date = today()
for row in mr.items:
row.qty = flt(row.qty) / 2
row.stock_qty = flt(row.stock_qty) / 2
mr.submit()
stock_entry = make_stock_entry(mr.name)
self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2)
selected_row = mr.items[0]
try:
frappe.flags.selected_children = {"items": [selected_row.name]}
selected_stock_entry = make_stock_entry(mr.name)
finally:
frappe.flags.selected_children = None
self.assertEqual(
[row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item]
)
self.assertEqual(selected_stock_entry.fg_completed_qty, 0)
for row in mr.items:
transferred_qty = flt(row.stock_qty) / 2
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty)
frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty)
mr.reload()
repeated_stock_entry = make_stock_entry(mr.name)
self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4)
@change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
def test_job_card_excess_material_transfer(self):
"Test transferring more than required RM against Job Card."
@@ -616,6 +653,7 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(ste.job_card, job_card_name)
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity"))
def test_job_card_proccess_qty_and_completed_qty(self):
from erpnext.manufacturing.doctype.routing.test_routing import (

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,22 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections.abc import Mapping
from frappe.utils import flt
def get_minimum_material_coverage_fraction(
required_qty: Mapping[str, float], transferred_qty: Mapping[str, float], precision: int
) -> float:
"""Return the least-covered component ratio at the configured quantity precision."""
coverage = []
for item_code, required in required_qty.items():
transferred = flt(transferred_qty.get(item_code))
# Stored values can differ after the digits that the user can enter or see.
if flt(transferred, precision) == flt(required, precision):
coverage.append(1.0)
else:
coverage.append(transferred / required)
return min(coverage, default=0.0)

View File

@@ -1461,9 +1461,11 @@ class TestWorkOrder(FrappeTestCase):
del transfer_entry.get("items")[0] # transfer only one RM
transfer_entry.submit()
# WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
# One required item is still missing, so no finished-good quantity is covered yet.
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
self.assertEqual(transfer_entry.fg_completed_qty, 0)
self.assertEqual(work_order.material_transferred_for_manufacturing, 0)
self.assertEqual(work_order.status, "In Process")
self.assertEqual(work_order.required_items[0].transferred_qty, 0)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
@@ -1483,6 +1485,47 @@ class TestWorkOrder(FrappeTestCase):
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
def test_material_transfer_claim_follows_actual_coverage(self):
work_order = make_wo_order_test_record(planned_start_date=now(), qty=4)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100",
target="_Test Warehouse - _TC",
qty=20,
basic_rate=1000.0,
)
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 4)
)
for row in transfer_entry.items:
if row.item_code == "_Test Item":
row.qty = 1
transfer_entry.submit()
work_order.reload()
self.assertEqual(transfer_entry.fg_completed_qty, 1)
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
remainder_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3)
)
remainder_entry.submit()
work_order.reload()
self.assertEqual(remainder_entry.fg_completed_qty, 3)
self.assertEqual(work_order.material_transferred_for_manufacturing, 4)
def test_material_coverage_cap_skips_manufacture_entry(self):
work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
manufacture_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
manufacture_entry.pro_doc = work_order
manufacture_entry._action = "submit"
self.assertFalse(manufacture_entry._should_cap_completed_qty())
def test_material_transferred_min_fraction_on_partial_pick_list(self):
"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
must reflect the least-transferred required item (the bottleneck), instead of being
@@ -1545,6 +1588,97 @@ class TestWorkOrder(FrappeTestCase):
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
def test_material_transferred_ignores_hidden_precision_difference(self):
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100",
target="_Test Warehouse - _TC",
qty=10,
basic_rate=1000.0,
)
precision = work_order.precision("required_qty", "required_items")
hidden_difference = 4 / (10 ** (precision + 1))
row = work_order.required_items[0]
row.db_set("required_qty", flt(row.required_qty) + hidden_difference, update_modified=False)
work_order.reload()
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
)
for item in transfer_entry.items:
item.qty = flt(required_qty[item.item_code], precision)
item.transfer_qty = item.qty
transfer_entry.submit()
work_order.reload()
self.assertEqual(
flt(work_order.required_items[0].required_qty, precision),
flt(work_order.required_items[0].transferred_qty, precision),
)
self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
def test_repair_material_transfer_precision_patch(self):
from erpnext.patches.v16_0.repair_work_order_material_transfer import (
execute,
get_precision_affected_work_orders,
)
precision = frappe.get_precision("Work Order Item", "required_qty")
hidden_difference = 4 / (10 ** (precision + 1))
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
for index, row in enumerate(work_order.required_items):
required_qty = flt(row.required_qty) + (hidden_difference if index == 0 else 0)
row.db_set(
{
"required_qty": required_qty,
"transferred_qty": flt(required_qty, precision),
},
update_modified=False,
)
work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
partial_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
for row in partial_work_order.required_items:
row.db_set("transferred_qty", row.required_qty, update_modified=False)
partial_row = partial_work_order.required_items[0]
partial_row.db_set(
"transferred_qty",
flt(partial_row.required_qty, precision) - (1 / (10**precision)),
update_modified=False,
)
partial_work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
terminal_work_orders = []
for status in ("Stopped", "Closed", "Completed"):
terminal_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
for row in terminal_work_order.required_items:
row.db_set("transferred_qty", row.required_qty, update_modified=False)
terminal_work_order.db_set(
{"material_transferred_for_manufacturing": 1.99, "status": status},
update_modified=False,
)
terminal_work_orders.append(terminal_work_order)
updates = get_precision_affected_work_orders()
self.assertIn(work_order.name, updates)
self.assertNotIn(partial_work_order.name, updates)
for terminal_work_order in terminal_work_orders:
self.assertNotIn(terminal_work_order.name, updates)
execute()
work_order.reload()
partial_work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
self.assertEqual(partial_work_order.material_transferred_for_manufacturing, 1.99)
for terminal_work_order in terminal_work_orders:
terminal_work_order.reload()
self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99)
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
@@ -3287,6 +3421,42 @@ class TestWorkOrder(FrappeTestCase):
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0)
def test_transferred_qty_sums_item_and_its_alternate(self):
# Base item + its alternate transfers must sum onto the required row, not overwrite.
fg_item = "Test FG Item For Alternate Transferred Qty"
source_warehouse = "Stores - _TC"
raw_material = "Test RM For Alternate Transferred Qty"
alternate_item = "Alternate Test RM For Alternate Transferred Qty"
make_item(fg_item, {"is_stock_item": 1})
for item in [raw_material, alternate_item]:
make_item(item, {"is_stock_item": 1, "allow_alternative_item": 1})
test_stock_entry.make_stock_entry(item_code=item, target=source_warehouse, qty=10, basic_rate=100)
frappe.get_doc(
{
"doctype": "Item Alternative",
"item_code": raw_material,
"alternative_item_code": alternate_item,
"two_way": 1,
}
).insert()
make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[raw_material])
wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse)
# 6 as the base item
frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 6)).submit()
# 4 as the alternate item, linked back to the base
alt_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 4))
alt_transfer.items[0].item_code = alternate_item
alt_transfer.items[0].original_item = raw_material
alt_transfer.submit()
wo.reload()
self.assertEqual(wo.required_items[0].transferred_qty, 10)
self.assertEqual(wo.material_transferred_for_manufacturing, 10)
def test_components_qty_for_bom_based_manufacture_entry(self):
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)

View File

@@ -31,6 +31,10 @@ from erpnext.manufacturing.doctype.bom.bom import (
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations,
)
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
get_minimum_material_coverage_fraction,
)
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos, get_serial_nos
@@ -254,7 +258,7 @@ class WorkOrder(Document):
PackedItem = frappe.qb.DocType("Packed Item")
ProductBundleItem = frappe.qb.DocType("Product Bundle Item")
so = (
so_query = (
frappe.qb.from_(SalesOrder)
.inner_join(SalesOrderItem)
.on(SalesOrderItem.parent == SalesOrder.name)
@@ -270,16 +274,23 @@ class WorkOrder(Document):
| (ProductBundleItem.item_code == production_item)
)
)
.run(as_dict=1)
)
if self.sales_order_item:
so_query = so_query.where(SalesOrderItem.name == self.sales_order_item)
so = so_query.run(as_dict=1)
if not so:
so = (
packed_so_query = (
frappe.qb.from_(SalesOrder)
.inner_join(SalesOrderItem)
.on(SalesOrderItem.parent == SalesOrder.name)
.inner_join(PackedItem)
.on(PackedItem.parent == SalesOrder.name)
.on(
(PackedItem.parent == SalesOrder.name)
& (PackedItem.parent_detail_docname == SalesOrderItem.name)
)
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
.where(
(SalesOrder.name == self.sales_order)
@@ -288,9 +299,16 @@ class WorkOrder(Document):
& (SalesOrder.docstatus == 1)
& (PackedItem.item_code == production_item)
)
.run(as_dict=1)
)
if self.sales_order_item:
packed_so_query = packed_so_query.where(
(PackedItem.name == self.sales_order_item)
| (SalesOrderItem.name == self.sales_order_item)
)
so = packed_so_query.run(as_dict=1)
if len(so):
if not self.expected_delivery_date:
self.expected_delivery_date = so[0].delivery_date
@@ -312,7 +330,18 @@ class WorkOrder(Document):
if not self.wip_warehouse and not self.skip_transfer:
self.wip_warehouse = frappe.db.get_single_value("Manufacturing Settings", "default_wip_warehouse")
if not self.fg_warehouse:
self.fg_warehouse = frappe.db.get_single_value("Manufacturing Settings", "default_fg_warehouse")
self.fg_warehouse = (
frappe.db.get_single_value("Manufacturing Settings", "default_fg_warehouse")
or self.get_production_item_warehouse()
)
def get_production_item_warehouse(self):
if not self.production_item:
return None
return get_item_defaults(self.production_item, self.company).get(
"default_warehouse"
) or get_item_group_defaults(self.production_item, self.company).get("default_warehouse")
def check_wip_warehouse_skip(self):
if self.skip_transfer and not self.from_wip_warehouse:
@@ -412,11 +441,7 @@ 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
or self.has_transferred_material()
):
if flt(self.material_transferred_for_manufacturing) > 0 or self.has_transferred_material():
status = "In Process"
precision = frappe.get_precision("Work Order", "produced_qty")
@@ -436,8 +461,7 @@ 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)."""
"""True if any raw material was transferred against this work order."""
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
qty = (
@@ -450,7 +474,6 @@ class WorkOrder(Document):
& (ste.docstatus == 1)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
& (ste.pick_list.isnotnull())
)
).run()[0][0]
return flt(qty) > 0
@@ -1231,7 +1254,10 @@ class WorkOrder(Document):
"description": item.description,
"allow_alternative_item": item.allow_alternative_item,
"required_qty": item.qty,
"source_warehouse": item.source_warehouse or item.default_warehouse,
"source_warehouse": item.source_warehouse
or item.default_warehouse
or self.source_warehouse
or get_item_group_defaults(item.item_code, self.company).get("default_warehouse"),
"include_item_in_manufacturing": item.include_item_in_manufacturing,
},
)
@@ -1260,11 +1286,15 @@ class WorkOrder(Document):
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
)
.groupby(ste_child.item_code)
.groupby(ste_child.item_code, ste_child.original_item)
)
data = query.run(as_dict=1) or []
transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
# An item's own transfer and its substitutes both key to the original item, so sum them.
transferred_items = frappe._dict()
for d in data:
key = d.original_item or d.item_code
transferred_items[key] = flt(transferred_items.get(key)) + flt(d.qty)
for row in self.required_items:
row.db_set(
@@ -1274,20 +1304,13 @@ class WorkOrder(Document):
self.recompute_material_transferred_for_manufacturing(transferred_items)
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
"""Set transferred quantity from the raw materials that have actually moved."""
# 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")
if sum_fg_completed_qty:
self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
return
claimed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
# so partial availability does not prematurely mark the work order as fully transferred.
required_by_item = {}
for row in self.required_items:
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
@@ -1297,12 +1320,13 @@ class WorkOrder(Document):
if not required_by_item:
return
min_fraction = min(
flt(transferred_items.get(item_code) or 0) / required_qty
for item_code, required_qty in required_by_item.items()
min_fraction = get_minimum_material_coverage_fraction(
required_by_item,
transferred_items,
self.precision("required_qty", "required_items"),
)
min_fraction = min(min_fraction, 1.0)
material_transferred = min_fraction * flt(self.qty)
covered_qty = min_fraction * flt(self.qty)
material_transferred = min(covered_qty, max(flt(self.qty), claimed_qty))
self.db_set("material_transferred_for_manufacturing", material_transferred)
def update_returned_qty(self):

View File

@@ -20,11 +20,11 @@
<hr style="margin: 15px -15px;">
<p>
{% if data.value %}
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="#Form/Work Order/{{ data.value }}">
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="#Form/Work Order/{{ frappe.utils.escape_html(data.value) }}">
{{ __("Open Work Order {0}", [data.value.bold()]) }}</a>
{% endif %}
{% if data.item_code %}
<a class="btn btn-default btn-xs" href="#Form/Item/{{ data.item_code }}">
<a class="btn btn-default btn-xs" href="#Form/Item/{{ frappe.utils.escape_html(data.item_code) }}">
{{ __("Open Item {0}", [data.item_code.bold()]) }}</a>
{% endif %}
</p>

View File

@@ -413,7 +413,7 @@ def get_workstations(**kwargs):
for d in data:
d.workstation_name = get_link_to_form("Workstation", d.name)
d.status_image = d.on_status_image
d.status_image = frappe.utils.escape_html(d.on_status_image)
d.background_color = color_map.get(d.status, "var(--red-600)")
d.workstation_link = get_url_to_form("Workstation", d.name)
if d.status != "Production":

View File

@@ -11,7 +11,7 @@
<div style = "max-height: 400px; overflow-y: auto;">
{% $.each(data, (idx, d) => { %}
<div class="row form-dashboard-section job-card-link form-links border-gray-200" data-name="{{d.name}}">
<div class="row form-dashboard-section job-card-link form-links border-gray-200" data-name="{{ frappe.utils.escape_html(d.name) }}">
<div class="section-head section-head-job-card">
{{ d.operation }} - {{ d.production_item }}
<span class="ml-2 collapse-indicator-job mb-1" style="">
@@ -64,8 +64,8 @@
</div>
</div>
<div class="form-column col-sm-2 text-center">
<button style="width: 85px;" class="btn btn-default btn-start {% if(d.status !== "Open") { %} hide {% } %}" job-card="{{d.name}}"> {{__("Start")}} </button>
<button style="width: 85px;" class="btn btn-default btn-complete {% if(d.status === "Open") { %} hide {% } %}" job-card="{{d.name}}" pending-qty="{{d.for_quantity - d.transferred_qty}}"> {{__("Complete")}} </button>
<button style="width: 85px;" class="btn btn-default btn-start {% if(d.status !== "Open") { %} hide {% } %}" job-card="{{ frappe.utils.escape_html(d.name) }}"> {{__("Start")}} </button>
<button style="width: 85px;" class="btn btn-default btn-complete {% if(d.status === "Open") { %} hide {% } %}" job-card="{{ frappe.utils.escape_html(d.name) }}" pending-qty="{{d.for_quantity - d.transferred_qty}}"> {{__("Complete")}} </button>
</div>
</div>
@@ -77,7 +77,7 @@
</div>
{% if(d.make_material_request) { %}
<div class="form-column col-sm-10 text-right">
<button class="btn btn-default btn-xs make-material-request" job-card="{{d.name}}">{{ __("Material Request") }}</button>
<button class="btn btn-default btn-xs make-material-request" job-card="{{ frappe.utils.escape_html(d.name) }}">{{ __("Material Request") }}</button>
</div>
{% } %}
</div>

View File

@@ -23,7 +23,10 @@ frappe.query_reports["Production Plan Summary"] = {
if (column.fieldname == "item_code") {
var color = data.pending_qty > 0 ? "red" : "green";
value = `<a style='color:${color}' href="/app/item/${data["item_code"]}" data-doctype="Item">${data["item_code"]}</a>`;
value = `<a style='color:${color}' href="${frappe.utils.get_form_link(
"Item",
data["item_code"]
)}" data-doctype="Item">${frappe.utils.escape_html(data["item_code"])}</a>`;
}
return value;

View File

@@ -447,3 +447,7 @@ erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v15_0.fix_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.repair_work_order_material_transfer
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
erpnext.patches.v16_0.add_transaction_roles_to_sms_settings

View File

@@ -0,0 +1,39 @@
import frappe
from frappe import _
STANDARD_TRANSACTION_ROLES = [
"Sales User",
"Sales Manager",
"Purchase User",
"Purchase Manager",
"Stock User",
"Stock Manager",
"Accounts User",
"Accounts Manager",
]
def execute():
"""Seed SMS Settings.allowed_roles with ERPNext's standard transaction roles."""
frappe.reload_doctype("SMS Settings")
if not frappe.get_meta("SMS Settings").has_field("allowed_roles"):
frappe.throw(
_(
"SMS Settings.allowed_roles not found. Update the Frappe Framework app to a "
"version that includes this field, then re-run bench migrate."
)
)
sms_settings = frappe.get_single("SMS Settings")
existing_roles = {d.role for d in sms_settings.get("allowed_roles")}
added = False
for role in STANDARD_TRANSACTION_ROLES:
if role not in existing_roles and frappe.db.exists("Role", role):
sms_settings.append("allowed_roles", {"role": role})
added = True
if added:
sms_settings.flags.ignore_mandatory = True
sms_settings.save()

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