Compare commits

..

78 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
78 changed files with 2562 additions and 289 deletions

View File

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

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

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

@@ -1526,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))
@@ -1571,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
@@ -1606,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"
)

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,
@@ -2201,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)
@@ -2215,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", [])
@@ -2251,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

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

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

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

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

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

@@ -1267,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"):

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

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

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

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

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

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

@@ -258,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)
@@ -274,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)
@@ -292,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
@@ -1272,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(

View File

@@ -449,3 +449,5 @@ 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()

View File

@@ -0,0 +1,20 @@
import frappe
def execute():
rows = frappe.get_all(
"POS Search Fields", filters={"parent": "POS Settings"}, fields=["name", "field", "fieldname"]
)
for row in rows:
# the row used to hold the label alone, it now holds "Label (fieldname)"
if not (row.field and row.fieldname) or row.field.endswith(f"({row.fieldname})"):
continue
frappe.db.set_value(
"POS Search Fields",
row.name,
"field",
f"{row.field} ({row.fieldname})",
update_modified=False,
)

View File

@@ -590,12 +590,16 @@ def allow_to_make_project_update(project, time, frequency):
@frappe.whitelist()
def create_duplicate_project(prev_doc, project_name):
def create_duplicate_project(prev_doc: str | dict, project_name: str):
"""Create duplicate project based on the old project"""
import json
prev_doc = json.loads(prev_doc)
# prev_doc is caller-supplied, but the tasks below are read from the db by name
if source_name := prev_doc.get("name"):
frappe.has_permission("Project", "read", source_name, throw=True)
if project_name == prev_doc.get("name"):
frappe.throw(_("Use a name that is different from previous project name"))
@@ -790,5 +794,6 @@ def calculate_total_purchase_cost(project: str | None = None):
@frappe.whitelist()
def update_costing_and_billing(project: str | None = None):
project = frappe.get_doc("Project", project)
project.check_permission("write")
project.update_costing()
project.db_update()

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Date
from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours, time_diff_in_seconds
from erpnext.controllers.queries import get_match_cond
@@ -322,45 +323,62 @@ class Timesheet(Document):
@frappe.whitelist()
def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to_time=None):
condition = ""
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
allowed_timesheets = frappe.get_list("Timesheet", pluck="name")
allowed_projects = frappe.get_list("Project", pluck="name")
if not allowed_timesheets:
return []
query = (
frappe.qb.from_(tsd)
.inner_join(ts)
.on(ts.name == tsd.parent)
.select(
tsd.name.as_("name"),
tsd.parent.as_("time_sheet"),
tsd.from_time.as_("from_time"),
tsd.to_time.as_("to_time"),
tsd.billing_hours.as_("billing_hours"),
tsd.billing_amount.as_("billing_amount"),
tsd.activity_type.as_("activity_type"),
tsd.description.as_("description"),
ts.currency.as_("currency"),
tsd.project_name.as_("project_name"),
)
.where(
(tsd.parenttype == "Timesheet")
& (tsd.docstatus == 1)
& (tsd.is_billable == 1)
& tsd.sales_invoice.isnull()
& (tsd.parent.isin(allowed_timesheets))
)
)
if allowed_projects:
query = query.where((tsd.project.isin(allowed_projects)) | (tsd.project.isnull()))
else:
query = query.where(tsd.project.isnull())
if project:
condition += "AND tsd.project = %(project)s "
query = query.where(tsd.project == project)
if parent:
condition += "AND tsd.parent = %(parent)s "
query = query.where(tsd.parent == parent)
if from_time and to_time:
condition += "AND CAST(tsd.from_time as DATE) BETWEEN %(from_time)s AND %(to_time)s"
query = query.where(Date(tsd.from_time).between(from_time, to_time))
query = f"""
SELECT
tsd.name as name,
tsd.parent as time_sheet,
tsd.from_time as from_time,
tsd.to_time as to_time,
tsd.billing_hours as billing_hours,
tsd.billing_amount as billing_amount,
tsd.activity_type as activity_type,
tsd.description as description,
ts.currency as currency,
tsd.project_name as project_name
FROM `tabTimesheet Detail` tsd
INNER JOIN `tabTimesheet` ts
ON ts.name = tsd.parent
WHERE
tsd.parenttype = 'Timesheet'
AND tsd.docstatus = 1
AND tsd.is_billable = 1
AND tsd.sales_invoice is NULL
{condition}
ORDER BY tsd.from_time ASC
"""
filters = {"project": project, "parent": parent, "from_time": from_time, "to_time": to_time}
return frappe.db.sql(query, filters, as_dict=1)
return query.orderby(tsd.from_time).run(as_dict=1)
@frappe.whitelist()
def get_timesheet_detail_rate(timelog, currency):
allowed_timesheets = frappe.get_list("Timesheet", pluck="name")
if not allowed_timesheets:
return 0.0
ts = frappe.qb.DocType("Timesheet")
ts_detail = frappe.qb.DocType("Timesheet Detail")
@@ -368,10 +386,20 @@ def get_timesheet_detail_rate(timelog, currency):
frappe.qb.from_(ts_detail)
.inner_join(ts)
.on(ts.name == ts_detail.parent)
.select(ts_detail.billing_amount.as_("billing_amount"), ts.currency.as_("currency"))
.where(ts_detail.name == timelog)
.select(
ts_detail.billing_amount.as_("billing_amount"),
ts.currency.as_("currency"),
ts.name.as_("timesheet"),
)
.where((ts_detail.name == timelog) & ts_detail.parent.isin(allowed_timesheets))
.limit(1)
.run(as_dict=1)
)[0]
)
if not timelog_detail:
return 0.0
timelog_detail = timelog_detail[0]
if timelog_detail.currency:
exchange_rate = get_exchange_rate(timelog_detail.currency, currency)
@@ -386,33 +414,42 @@ def get_timesheet(doctype, txt, searchfield, start, page_len, filters):
if not filters:
filters = {}
condition = ""
if filters.get("project"):
condition = "and tsd.project = %(project)s"
allowed_timesheets = frappe.get_list("Timesheet", pluck="name")
return frappe.db.sql(
f"""select distinct tsd.parent from `tabTimesheet Detail` tsd,
`tabTimesheet` ts where
ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and
tsd.docstatus = 1 and ts.total_billable_amount > 0
and tsd.parent LIKE %(txt)s {condition}
order by tsd.parent limit %(page_len)s offset %(start)s""",
{
"txt": "%" + txt + "%",
"start": start,
"page_len": page_len,
"project": filters.get("project"),
},
if not allowed_timesheets:
return []
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
query = (
frappe.qb.from_(tsd)
.inner_join(ts)
.on(tsd.parent == ts.name)
.select(tsd.parent)
.distinct()
.where(
ts.status.isin(["Submitted", "Payslip"])
& (tsd.docstatus == 1)
& (ts.total_billable_amount > 0)
& tsd.parent.like(f"%{txt}%")
& tsd.parent.isin(allowed_timesheets)
)
)
if filters.get("project"):
query = query.where(tsd.project == filters.get("project"))
return query.orderby(tsd.parent).limit(page_len).offset(start).run()
@frappe.whitelist()
def get_timesheet_data(name, project):
def get_timesheet_data(name, project=None):
data = None
if project and project != "":
if project:
data = get_projectwise_timesheet_data(project, name)
else:
data = frappe.get_all(
data = frappe.get_list(
"Timesheet",
fields=[
"(total_billable_amount - total_billed_amount) as billing_amt",
@@ -546,8 +583,14 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20
customer = contact.get_link_for("Customer")
if customer:
sales_invoices = frappe.get_all("Sales Invoice", filters={"customer": customer}, pluck="name")
sales_invoices = frappe.get_all(
"Sales Invoice",
filters={"customer": customer, "docstatus": ["!=", 2]},
pluck="name",
)
projects = frappe.get_all("Project", filters={"customer": customer}, pluck="name")
if not (sales_invoices or projects):
return []
# Return timesheet related data to web portal.
table = frappe.qb.DocType("Timesheet")
@@ -577,10 +620,7 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20
if projects:
conditions.append(child_table.project.isin(projects))
if conditions:
query = query.where(frappe.qb.terms.Criterion.any(conditions))
return query.run(as_dict=True)
return query.where(frappe.qb.terms.Criterion.any(conditions)).run(as_dict=True)
else:
return {}

View File

@@ -2367,8 +2367,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
method: me.get_method_for_payment(),
args: args,
callback: function(r) {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
if (!r.exc) {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
}
}
});
}

View File

@@ -982,7 +982,7 @@ erpnext.utils.map_current_doc = function (opts) {
if (already_set) {
frappe.msgprint(
__("You have already selected items from {0} {1}", [opts.source_doctype, src])
__("You have already selected items from {0} {1}", [__(opts.source_doctype), src])
);
return;
}

View File

@@ -81,7 +81,7 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
if (
!erpnext.utils.validate_mandatory(
frm,
"Posting / Transaction Date",
__("Posting / Transaction Date"),
args.posting_date,
args.party_type == "Customer" ? "customer" : "supplier"
)
@@ -92,7 +92,7 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
if (
!erpnext.utils.validate_mandatory(
frm,
"Company",
__("Company"),
frm.doc.company,
args.party_type == "Customer" ? "customer" : "supplier"
)
@@ -177,7 +177,7 @@ erpnext.utils.set_taxes_from_address = function (
if (
!erpnext.utils.validate_mandatory(
frm,
"Lead / Customer / Supplier",
__("Lead / Customer / Supplier"),
frm.doc.customer || frm.doc.supplier || frm.doc.lead || frm.doc.party_name,
triggered_from_field
)
@@ -188,7 +188,7 @@ erpnext.utils.set_taxes_from_address = function (
if (
!erpnext.utils.validate_mandatory(
frm,
"Posting / Transaction Date",
__("Posting / Transaction Date"),
frm.doc.posting_date || frm.doc.transaction_date,
triggered_from_field
)
@@ -220,14 +220,14 @@ erpnext.utils.set_taxes_from_address = function (
erpnext.utils.set_taxes = function (frm, triggered_from_field) {
if (frappe.meta.get_docfield(frm.doc.doctype, "taxes")) {
if (!erpnext.utils.validate_mandatory(frm, "Company", frm.doc.company, triggered_from_field)) {
if (!erpnext.utils.validate_mandatory(frm, __("Company"), frm.doc.company, triggered_from_field)) {
return;
}
if (
!erpnext.utils.validate_mandatory(
frm,
"Lead / Customer / Supplier",
__("Lead / Customer / Supplier"),
frm.doc.customer || frm.doc.supplier || frm.doc.lead || frm.doc.party_name,
triggered_from_field
)
@@ -238,7 +238,7 @@ erpnext.utils.set_taxes = function (frm, triggered_from_field) {
if (
!erpnext.utils.validate_mandatory(
frm,
"Posting / Transaction Date",
__("Posting / Transaction Date"),
frm.doc.posting_date || frm.doc.transaction_date,
triggered_from_field
)

View File

@@ -14,7 +14,7 @@ from frappe.contacts.address_and_contact import (
from frappe.model.mapper import get_mapped_doc
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
from frappe.model.utils.rename_doc import update_linked_doctypes
from frappe.utils import cint, cstr, flt, get_formatted_email, today
from frappe.utils import cint, cstr, flt, get_formatted_email, get_link_to_form, today
from frappe.utils.deprecations import deprecated
from frappe.utils.user import get_users_with_role
@@ -227,10 +227,15 @@ class Customer(TransactionBase):
)
if internal_customer:
internal_customer_link = get_link_to_form("Customer", internal_customer)
frappe.throw(
_("Internal Customer for company {0} already exists").format(
frappe.bold(self.represents_company)
)
_(
"Internal Customer {0} already exists for {1}. Disable it to make this Customer internal."
).format(
internal_customer_link,
frappe.bold(self.represents_company),
),
title=_("Internal Customer Already Exists"),
)
def on_update(self):

View File

@@ -346,6 +346,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",
@@ -1107,7 +1108,7 @@
"idx": 82,
"is_submittable": 1,
"links": [],
"modified": "2026-05-30 17:40:02.667637",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",

View File

@@ -9,6 +9,7 @@ from frappe import _
from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, flt, getdate, nowdate
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.controllers.selling_controller import SellingController
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -257,7 +258,11 @@ class Quotation(SellingController):
opp.set_status(status=status, update=True)
@frappe.whitelist()
def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None):
def declare_enquiry_lost(
self, lost_reasons_list: list, competitors: list, detailed_reason: str | None = None
):
self.check_permission("write")
if not (self.is_fully_ordered() or self.is_partially_ordered()):
get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"])
lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons]
@@ -387,6 +392,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
customer = _make_customer(source_name, ignore_permissions)
ordered_items = get_ordered_items(source_name)
mapped_items = get_qty_already_mapped(target_doc, "quotation_item", "stock_qty")
for name, stock_qty in mapped_items.items():
ordered_items[name] = flt(ordered_items.get(name)) + stock_qty
selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
@@ -440,7 +448,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
3. If no selections: Simple row: Map if adequate qty
"""
if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
if not (
(item.stock_qty > ordered_items.get(item.name, 0.0))
or (is_unit_price_row(item) and item.name not in mapped_items)
):
return False
if not selected_rows:

View File

@@ -478,6 +478,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"hide_days": 1,
@@ -1679,7 +1680,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-07-28 12:20:44.130918",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",

View File

@@ -22,6 +22,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
validate_inter_company_party,
)
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.controllers.selling_controller import SellingController
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order,
@@ -977,6 +978,8 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
if kwargs.for_reserved_stock:
sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name)
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
mapper = {
"Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
@@ -1031,15 +1034,17 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
return False
return (
(abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)
(abs(doc.delivered_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty))
or (is_unit_price_row(doc) and doc.name not in mapped_qty_by_item)
) and doc.delivered_by_supplier != 1
def remaining_qty(source):
return flt(source.qty) - flt(source.delivered_qty) - flt(mapped_qty_by_item.get(source.name, 0))
def update_item(source, target, source_parent):
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
target.qty = (
flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty)
)
target.base_amount = remaining_qty(source) * flt(source.base_rate)
target.amount = remaining_qty(source) * flt(source.rate)
target.qty = flt(source.qty) if is_unit_price_row(source) else remaining_qty(source)
item = get_item_defaults(target.item_code, source_parent.company)
item_group = get_item_group_defaults(target.item_code, source_parent.company)
@@ -1137,6 +1142,7 @@ def make_sales_invoice(
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
billed_qty_by_item = None
pending_qty_by_item = {}
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
@@ -1165,6 +1171,7 @@ def make_sales_invoice(
if source.qty and source.billed_amt:
billable_qty -= get_billed_qty_by_item().get(source.name, 0)
billable_qty -= mapped_qty_by_item.get(source.name, 0)
pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0)
return pending_qty_by_item[source.name]
@@ -1244,7 +1251,7 @@ def make_sales_invoice(
"postprocess": update_item,
"condition": lambda doc: select_item(doc)
and (
True
doc.name not in mapped_qty_by_item
if is_unit_price_row(doc)
else (
doc.qty
@@ -1663,8 +1670,10 @@ def is_product_bundle(item_code):
@frappe.whitelist()
def make_work_orders(items, sales_order, company, project=None):
def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None):
"""Make Work Orders against the given Sales Order for the given `items`"""
frappe.has_permission("Sales Order", "read", sales_order, throw=True)
items = json.loads(items).get("items")
out = []

View File

@@ -1730,6 +1730,61 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
)
self.assertEqual(wo_qty[0][0], so_item_name.get(item))
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_make_work_order_for_duplicate_product_bundle_rows(self):
from erpnext.selling.doctype.sales_order.sales_order import get_work_order_items
bundle_item = make_item("_Test Work Order Product Bundle", {"is_stock_item": 0}).name
make_product_bundle(bundle_item, ["_Test FG Item"])
first_delivery_date = add_days(today(), 5)
second_delivery_date = add_days(today(), 10)
so = make_sales_order(
item_list=[
{
"item_code": bundle_item,
"qty": 1,
"rate": 100,
"warehouse": "_Test Warehouse - _TC",
"delivery_date": first_delivery_date,
},
{
"item_code": bundle_item,
"qty": 1,
"rate": 100,
"warehouse": "_Test Warehouse - _TC",
"delivery_date": second_delivery_date,
},
]
)
items = [
{
"warehouse": item.get("warehouse"),
"item_code": item.get("item_code"),
"pending_qty": item.get("pending_qty"),
"sales_order_item": item.get("sales_order_item"),
"bom": item.get("bom"),
"description": item.get("description"),
}
for item in get_work_order_items(so.name)
]
work_orders = make_work_orders(json.dumps({"items": items}), so.name, so.company)
expected_delivery_dates = {
packed_item.name: next(
item.delivery_date for item in so.items if item.name == packed_item.parent_detail_docname
)
for packed_item in so.packed_items
}
self.assertEqual(len(work_orders), 2)
for work_order_name in work_orders:
work_order = frappe.get_doc("Work Order", work_order_name)
self.assertEqual(
getdate(work_order.expected_delivery_date),
getdate(expected_delivery_dates[work_order.sales_order_item]),
)
def test_advance_payment_entry_unlink_against_sales_order(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry

View File

@@ -2,6 +2,18 @@
// For license information, please see license.txt
frappe.query_reports["Sales Analytics"] = {
// "All" reports on every doctype at once and forces the tree to Customer
entity_tree_type() {
const doc_type = frappe.query_report.get_filter_value("doc_type");
return doc_type === "All" ? "Customer" : frappe.query_report.get_filter_value("tree_type");
},
reset_entity_filter() {
const entity_filter = frappe.query_report.get_filter("entity");
if (!entity_filter) return;
entity_filter.df.label = __(this.entity_tree_type());
entity_filter.set_value([]);
entity_filter.refresh();
},
filters: [
{
fieldname: "tree_type",
@@ -18,6 +30,21 @@ frappe.query_reports["Sales Analytics"] = {
],
default: "Customer",
reqd: 1,
on_change: function () {
frappe.query_reports["Sales Analytics"].reset_entity_filter();
frappe.query_report.refresh();
},
},
{
fieldname: "entity",
label: __("Entity"),
fieldtype: "MultiSelectList",
get_data: function (txt) {
const tree_type = frappe.query_reports["Sales Analytics"].entity_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",
@@ -26,6 +53,10 @@ frappe.query_reports["Sales Analytics"] = {
options: ["Sales Order", "Delivery Note", "Sales Invoice"],
default: "Sales Invoice",
reqd: 1,
on_change: function () {
frappe.query_reports["Sales Analytics"].reset_entity_filter();
frappe.query_report.refresh();
},
},
{
fieldname: "value_quantity",

View File

@@ -18,6 +18,7 @@ def execute(filters=None):
class Analytics:
def __init__(self, filters=None):
self.filters = frappe._dict(filters or {})
self.entities = self.filters.get("entity") or []
self.date_field = (
"transaction_date"
if self.filters.doc_type in ["Sales Order", "Purchase Order"]
@@ -61,6 +62,7 @@ class Analytics:
self.update_company_list_for_parent_company()
self.get_columns()
self.get_data()
self.filter_data_by_entities()
self.get_chart_data()
# Skipping total row for tree-view reports
@@ -325,6 +327,23 @@ class Analytics:
filters=filters,
)
def filter_data_by_entities(self):
if not self.entities:
return
entities = set(self.entities)
selected_data = []
for row in self.data:
if row["entity"] not in entities:
continue
row = row.copy()
if "indent" in row:
row["indent"] = 0
selected_data.append(row)
self.data = selected_data
def get_rows(self):
self.data = []
self.get_periodic_data()

View File

@@ -0,0 +1,253 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. 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.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_analytics.sales_analytics import execute
# Bootstrap masters reused as-is (see erpnext/tests/utils.py):
# "_Test Customer" -> customer_group "_Test Customer Group", territory "_Test Territory"
# "_Test Supplier" -> supplier_group "_Test Supplier Group" (child of "All Supplier Groups")
# Sales Order.order_type defaults to "Sales" (reqd Select field)
COMPANY = "_Test Company"
CUSTOMER = "_Test Customer"
CUSTOMER_GROUP = "_Test Customer Group"
TERRITORY = "_Test Territory"
SUPPLIER = "_Test Supplier"
SUPPLIER_GROUP = "_Test Supplier Group"
FROM_DATE = "2019-04-01"
TO_DATE = "2019-06-30"
class TestSalesAnalytics(FrappeTestCase):
def setUp(self):
frappe.set_user("Administrator")
self.created_docs = []
# Two submitted Sales Orders for the bootstrap customer inside the report window.
# These roll up into the tree roots the converted tree/order-type queries build.
self.orders = [
self.make_so(qty=5, rate=100, transaction_date="2019-04-10"),
self.make_so(qty=3, rate=100, transaction_date="2019-05-15"),
]
def tearDown(self):
for doctype, name in reversed(self.created_docs):
if not frappe.db.exists(doctype, name):
continue
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=True)
super().tearDown()
def make_so(self, qty, rate, transaction_date, order_type=None):
so = make_sales_order(
company=COMPANY,
customer=CUSTOMER,
qty=qty,
rate=rate,
transaction_date=transaction_date,
do_not_save=True,
)
# v15's test helper does not populate these hidden analytics dimensions.
so.customer_group = CUSTOMER_GROUP
so.territory = TERRITORY
if order_type:
so.order_type = order_type
so.insert()
so.submit()
self.created_docs.append((so.doctype, so.name))
return so
def _base_filters(self, **overrides):
filters = {
"doc_type": "Sales Order",
"value_quantity": "Value",
"range": "Monthly",
"company": COMPANY,
"from_date": FROM_DATE,
"to_date": TO_DATE,
}
filters.update(overrides)
return filters
def _expected_value_total(self):
return sum(flt(so.base_net_total) for so in self.orders)
def _expected_qty_total(self):
return sum(flt(so.total_qty) for so in self.orders)
def _row_by_entity(self, data):
return {row["entity"]: row for row in data}
def test_customer_entity_filter(self):
_columns, data, _message, chart, *_rest = execute(
self._base_filters(tree_type="Customer", entity=[CUSTOMER], curves="all")
)
self.assertEqual({row["entity"] for row in data}, {CUSTOMER})
self.assertAlmostEqual(data[0]["total"], self._expected_value_total(), places=2)
self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {CUSTOMER})
def test_parent_customer_group_filter_preserves_rollup(self):
_columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group"))
_columns, filtered_data, *_rest = execute(
self._base_filters(tree_type="Customer Group", entity=["All Customer Groups"])
)
unfiltered = self._row_by_entity(unfiltered_data)
filtered = self._row_by_entity(filtered_data)
self.assertEqual(set(filtered), {"All Customer Groups"})
self.assertAlmostEqual(
filtered["All Customer Groups"]["total"],
unfiltered["All Customer Groups"]["total"],
places=2,
)
def test_customer_group_entity_filter(self):
_columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group"))
_columns, filtered_data, *_rest = execute(
self._base_filters(tree_type="Customer Group", entity=[CUSTOMER_GROUP])
)
unfiltered = self._row_by_entity(unfiltered_data)
filtered = self._row_by_entity(filtered_data)
self.assertEqual(set(filtered), {CUSTOMER_GROUP})
self.assertEqual(filtered[CUSTOMER_GROUP]["indent"], 0)
self.assertAlmostEqual(
filtered[CUSTOMER_GROUP]["total"], unfiltered[CUSTOMER_GROUP]["total"], places=2
)
def test_customer_group_tree_rolls_up_to_root(self):
"""tree_type='Customer Group' drives get_groups (tree get_all ordered by lft)
and get_rows_by_group, rolling child values up to the 'All Customer Groups' root."""
columns, data, *_ = execute(self._base_filters(tree_type="Customer Group"))
self.assertTrue(columns)
self.assertTrue(data)
rows = self._row_by_entity(data)
# The whole tree is returned, so both the root and the customer's own group appear.
self.assertIn("All Customer Groups", rows)
self.assertIn(CUSTOMER_GROUP, rows)
expected = self._expected_value_total()
self.assertGreater(expected, 0)
# Leaf group holds the orders; root receives the same total via roll-up.
self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected, places=2)
self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected, places=2)
# Roots of a tree report sit at indent 0.
self.assertEqual(rows["All Customer Groups"]["indent"], 0)
def test_territory_tree_rolls_up_to_root(self):
"""tree_type='Territory' exercises the same tree path against the Territory tree."""
columns, data, *_ = execute(self._base_filters(tree_type="Territory"))
self.assertTrue(columns)
rows = self._row_by_entity(data)
self.assertIn("All Territories", rows)
self.assertIn(TERRITORY, rows)
expected = self._expected_value_total()
self.assertAlmostEqual(rows[TERRITORY]["total"], expected, places=2)
self.assertAlmostEqual(rows["All Territories"]["total"], expected, places=2)
def test_order_type_synthetic_tree(self):
"""tree_type='Order Type' drives get_teams: distinct order_type rebuilt in Python
under a synthetic 'Order Types' root, then rolled up via get_rows_by_group."""
columns, data, *_ = execute(self._base_filters(tree_type="Order Type"))
self.assertTrue(columns)
rows = self._row_by_entity(data)
# Synthetic root plus the default order_type the bootstrap Sales Orders carry.
self.assertIn("Order Types", rows)
self.assertIn("Sales", rows)
self.assertEqual(rows["Order Types"]["indent"], 0)
expected = self._expected_value_total()
self.assertAlmostEqual(rows["Sales"]["total"], expected, places=2)
self.assertAlmostEqual(rows["Order Types"]["total"], expected, places=2)
def test_order_type_leaf_rows_in_sorted_order(self):
"""get_teams fetches distinct order_types; frappe drops the SQL ORDER BY for distinct queries on
postgres, so the report sorts the order-type rows in python (key=str.casefold) to keep them in a
deterministic, case-insensitive order identical on both engines."""
for order_type in ("Shopping Cart", "Maintenance", "Sales"): # created out of sorted order
self.make_so(
qty=1,
rate=100,
transaction_date="2019-04-12",
order_type=order_type,
)
columns, data, *_ = execute(self._base_filters(tree_type="Order Type"))
mine = {"Sales", "Maintenance", "Shopping Cart"}
leaves = [row["entity"] for row in data if row.get("entity") in mine]
# the order-type rows must appear in casefold-sorted order on both engines
self.assertEqual(leaves, sorted(leaves, key=str.casefold))
self.assertEqual(set(leaves), mine)
def test_customer_group_by_quantity(self):
"""value_quantity='Quantity' switches the selected value column (total_qty)."""
_columns, data, *_ = execute(
self._base_filters(tree_type="Customer Group", value_quantity="Quantity")
)
rows = self._row_by_entity(data)
self.assertIn(CUSTOMER_GROUP, rows)
expected_qty = self._expected_qty_total()
self.assertGreater(expected_qty, 0)
self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected_qty, places=2)
self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected_qty, places=2)
def test_supplier_group_tree_maps_supplier_to_group(self):
"""tree_type='Supplier Group' (doc_type='Purchase Order') exercises
get_supplier_parent_child_map: the query selects 'supplier' as entity, then
get_periodic_data remaps each supplier to its group via the parent->child map
built by frappe.get_all('Supplier', ['name', 'supplier_group'], as_list=True).
The group total then rolls up into the 'All Supplier Groups' root."""
# Baseline the report before adding our Purchase Order so the assertion is
# robust to any pre-existing rows in the historical window.
base_filters = self._base_filters(tree_type="Supplier Group", doc_type="Purchase Order")
_columns, base_data, *_ = execute(base_filters)
base_rows = self._row_by_entity(base_data)
base_group_total = flt(base_rows.get(SUPPLIER_GROUP, {}).get("total", 0.0))
po = create_purchase_order(
company=COMPANY,
supplier=SUPPLIER,
qty=4,
rate=250,
transaction_date="2019-04-10",
)
self.created_docs.append((po.doctype, po.name))
po_value = flt(po.base_net_total)
self.assertGreater(po_value, 0)
columns, data, *_ = execute(base_filters)
self.assertTrue(columns)
self.assertTrue(data)
rows = self._row_by_entity(data)
# The supplier was remapped to its group; both the leaf group and the tree
# root appear as entities (no raw supplier name leaks into the output).
self.assertIn(SUPPLIER_GROUP, rows)
self.assertIn("All Supplier Groups", rows)
self.assertNotIn(SUPPLIER, rows)
# Roots of a tree report sit at indent 0.
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
# The new PO lands in the supplier's group via the parent->child map.
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group_total, po_value, places=2)
# Roll-up: the root aggregates every group, so it covers at least this PO.
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), po_value)

View File

@@ -667,6 +667,13 @@ class Company(NestedSet):
"""
Trash accounts and cost centers for this company if no gl entry exists
"""
if frappe.db.get_single_value("Global Defaults", "demo_company") == self.name:
frappe.throw(
_("{0} is the site's Demo Company and cannot be deleted directly. Use {1} instead.").format(
self.name, _("Delete Demo Data")
)
)
NestedSet.validate_if_child_exists(self)
frappe.utils.nestedset.update_nsm(self)

View File

@@ -1395,6 +1395,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1413,6 +1414,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1431,6 +1433,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1449,6 +1452,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1467,6 +1471,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1485,6 +1490,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1507,6 +1513,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1525,6 +1532,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1543,6 +1551,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1561,6 +1570,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1579,6 +1589,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1597,6 +1608,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1628,6 +1640,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1637,6 +1650,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1646,6 +1660,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1655,6 +1670,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1664,6 +1680,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1673,6 +1690,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1682,6 +1700,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1691,6 +1710,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1700,6 +1720,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1709,6 +1730,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1718,6 +1740,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1727,6 +1750,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -1735,6 +1759,7 @@
"account_number": "1433",
"root_type": "Asset"
},
"not_applicable": 1,
"tax_rate": 0.00
}
]
@@ -2158,6 +2183,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2176,6 +2202,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2194,6 +2221,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2212,6 +2240,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2230,6 +2259,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2248,6 +2278,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2270,6 +2301,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2288,6 +2320,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2306,6 +2339,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2324,6 +2358,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2342,6 +2377,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2360,6 +2396,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2391,6 +2428,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2400,6 +2438,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2409,6 +2448,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2418,6 +2458,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2427,6 +2468,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2436,6 +2478,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2445,6 +2488,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2454,6 +2498,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2463,6 +2508,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2472,6 +2518,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2481,6 +2528,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2490,6 +2538,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2498,6 +2547,7 @@
"account_number": "1588",
"root_type": "Asset"
},
"not_applicable": 1,
"tax_rate": 0.00
}
]
@@ -2921,6 +2971,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2939,6 +2990,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2957,6 +3009,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2975,6 +3028,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -2993,6 +3047,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3011,6 +3066,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3033,6 +3089,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3051,6 +3108,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3069,7 +3127,8 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"tax_rate": 19.00
"not_applicable": 1,
"tax_rate": 0.00
},
{
"tax_type": {
@@ -3087,6 +3146,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3105,6 +3165,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3123,6 +3184,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3154,6 +3216,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3163,6 +3226,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3172,6 +3236,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3181,6 +3246,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3190,6 +3256,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3199,6 +3266,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3208,6 +3276,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3217,6 +3286,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3226,6 +3296,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3235,6 +3306,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3244,6 +3316,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3253,6 +3326,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3261,6 +3335,7 @@
"account_number": "1550",
"root_type": "Asset"
},
"not_applicable": 1,
"tax_rate": 0.00
}
]
@@ -3653,6 +3728,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3669,6 +3745,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3685,6 +3762,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3701,6 +3779,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3717,6 +3796,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3733,6 +3813,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3753,6 +3834,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3769,6 +3851,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3785,6 +3868,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3801,6 +3885,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3817,6 +3902,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3833,6 +3919,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3861,6 +3948,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3869,6 +3957,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3877,6 +3966,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3885,6 +3975,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3893,6 +3984,7 @@
"root_type": "Liability",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3901,6 +3993,7 @@
"root_type": "Liability",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3909,6 +4002,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3917,6 +4011,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3925,6 +4020,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3933,6 +4029,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3941,6 +4038,7 @@
"root_type": "Asset",
"tax_rate": 19.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3949,6 +4047,7 @@
"root_type": "Asset",
"tax_rate": 7.00
},
"not_applicable": 1,
"tax_rate": 0.00
},
{
@@ -3956,6 +4055,7 @@
"account_name": "Entstandene Einfuhrumsatzsteuer",
"root_type": "Asset"
},
"not_applicable": 1,
"tax_rate": 0.00
}
]

View File

@@ -28,6 +28,68 @@ class TestBin(FrappeTestCase):
frappe.db.rollback()
def test_repost_resets_bin_without_sle(self):
"""A repost must zero the bin when the ledger is empty, e.g. after entries were deleted."""
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.stock_ledger import update_entries_after
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
# deleting a transaction with `delete_linked_ledger_entries` on drops its entries outright
frappe.db.delete("Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse})
update_entries_after(
{
"item_code": item_code,
"warehouse": warehouse,
"posting_date": "1900-01-01",
"posting_time": "00:01",
}
)
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
self.assertEqual(bin.actual_qty, 0)
self.assertEqual(bin.valuation_rate, 0)
self.assertEqual(bin.stock_value, 0)
def test_cancelling_last_entry_resets_bin(self):
"""Cancelling the only voucher must clear stock value, not just quantity."""
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
se.cancel()
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
self.assertEqual(bin.actual_qty, 0)
self.assertEqual(bin.valuation_rate, 0)
self.assertEqual(bin.stock_value, 0)
def test_deleting_last_voucher_resets_bin(self):
"""Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared."""
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
delete_entries = frappe.get_single_value("Accounts Settings", "delete_linked_ledger_entries")
frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 1)
try:
se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
se.cancel()
frappe.delete_doc("Stock Entry", se.name, force=1)
finally:
frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", delete_entries)
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
self.assertEqual(bin.actual_qty, 0)
self.assertEqual(bin.valuation_rate, 0)
self.assertEqual(bin.stock_value, 0)
def test_index_exists(self):
indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1)
if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes):

View File

@@ -428,6 +428,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",
@@ -1404,7 +1405,7 @@
"idx": 146,
"is_submittable": 1,
"links": [],
"modified": "2026-02-03 12:27:19.055918",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",

View File

@@ -18,6 +18,7 @@ from frappe.utils import cint, flt
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.stock_ledger import validate_reserved_stock
@@ -936,6 +937,8 @@ def make_sales_invoice(
to_make_invoice_qty_map = {}
returned_qty_map = get_returned_qty_map(source_name)
invoiced_qty_map = get_invoiced_qty_map(source_name)
for ref, qty in get_qty_already_mapped(target_doc, "dn_detail").items():
invoiced_qty_map[ref] = invoiced_qty_map.get(ref, 0) + qty
def set_missing_values(source, target):
target.run_method("set_missing_values")
@@ -1008,7 +1011,7 @@ def make_sales_invoice(
"postprocess": update_item,
"filter": lambda d: get_pending_qty(d) <= 0
if not doc.get("is_return")
else get_pending_qty(d) > 0,
else get_pending_qty(d) >= 0,
"condition": select_item,
},
"Sales Taxes and Charges": {
@@ -1263,7 +1266,8 @@ def make_sales_return(source_name, target_doc=None):
@frappe.whitelist()
def update_delivery_note_status(docname, status):
dn = frappe.get_doc("Delivery Note", docname)
dn = frappe.get_lazy_doc("Delivery Note", docname)
dn.check_permission("submit")
dn.update_status(status)

View File

@@ -1091,6 +1091,25 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(dn2.per_billed, 100)
self.assertEqual(dn2.status, "Completed")
def test_mapping_same_dn_twice_is_idempotent(self):
# "Get Items From > Delivery Note" passes the in-progress invoice back as target_doc.
# Selecting the same DN again must not append a second row for the same dn_detail.
dn = create_delivery_note(qty=5)
si = make_sales_invoice(dn.name)
self.assertEqual(len(si.items), 1)
self.assertEqual(si.items[0].qty, 5)
si = make_sales_invoice(dn.name, target_doc=si)
self.assertEqual(len(si.items), 1)
self.assertEqual(si.items[0].qty, 5)
# a partly reduced draft row still tops up to the delivered qty
si.items[0].qty = 2
si = make_sales_invoice(dn.name, target_doc=si)
self.assertEqual(len(si.items), 2)
self.assertEqual([d.qty for d in si.items], [2, 3])
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": True})
def test_sales_invoice_qty_after_return(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return

View File

@@ -13,10 +13,13 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
DoNotChangeError,
delete_dimension,
)
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import InventoryDimensionNegativeStockError
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import (
InventoryDimensionNegativeStockError,
SerialNoInventoryDimensionError,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
@@ -504,6 +507,193 @@ class TestInventoryDimension(FrappeTestCase):
self.assertEqual(site_name, "Site 1")
def test_serial_no_cannot_be_issued_from_incorrect_inventory_dimension(self):
item = make_item(
"Test Serialized Inventory Dimension Item",
{"has_serial_no": 1, "is_stock_item": 1},
)
serial_no = "Test Serialized Inventory Dimension Serial No"
warehouse = create_warehouse("Serialized Inventory Dimension Warehouse")
create_inventory_dimension(
apply_to_all_doctypes=1,
dimension_name="Serial Rack",
reference_document="Rack",
validate_negative_stock=0,
)
receipt = make_stock_entry(
item_code=item.name,
to_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
do_not_submit=True,
)
receipt.items[0].to_serial_rack = "Rack 1"
receipt.save()
receipt.submit()
transfer = make_stock_entry(
item_code=item.name,
from_warehouse=warehouse,
to_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
do_not_submit=True,
)
transfer.items[0].serial_rack = "Rack 1"
transfer.items[0].to_serial_rack = "Rack 2"
transfer.save()
transfer.submit()
issue = make_stock_entry(
item_code=item.name,
from_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
do_not_submit=True,
)
issue.items[0].serial_rack = "Rack 1"
issue.save()
self.assertRaises(SerialNoInventoryDimensionError, issue.submit)
self.assertFalse(
frappe.db.exists(
"Stock Ledger Entry",
{"voucher_no": issue.name, "is_cancelled": 0},
)
)
def test_serial_no_cannot_move_from_empty_inventory_dimension(self):
item = make_item(
"Test Serialized Empty Inventory Dimension Item",
{"has_serial_no": 1, "is_stock_item": 1},
)
serial_no = "Test Serialized Empty Inventory Dimension Serial No"
warehouse = create_warehouse("Serialized Empty Inventory Dimension Warehouse")
create_inventory_dimension(
apply_to_all_doctypes=1,
dimension_name="Empty Serial Rack",
reference_document="Rack",
validate_negative_stock=0,
)
make_stock_entry(
item_code=item.name,
to_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
)
issue = make_stock_entry(
item_code=item.name,
from_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
do_not_submit=True,
)
issue.items[0].empty_serial_rack = "Rack 1"
issue.save()
self.assertRaises(SerialNoInventoryDimensionError, issue.submit)
def test_serial_no_cannot_be_issued_without_inventory_dimension(self):
item = make_item(
"Test Serialized Required Inventory Dimension Item",
{"has_serial_no": 1, "is_stock_item": 1},
)
serial_no = "Test Serialized Required Inventory Dimension Serial No"
warehouse = create_warehouse("Serialized Required Inventory Dimension Warehouse")
create_inventory_dimension(
apply_to_all_doctypes=1,
dimension_name="Required Serial Rack",
reference_document="Rack",
validate_negative_stock=0,
)
receipt = make_stock_entry(
item_code=item.name,
to_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
do_not_submit=True,
)
receipt.items[0].to_required_serial_rack = "Rack 1"
receipt.save()
receipt.submit()
issue = make_stock_entry(
item_code=item.name,
from_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
do_not_submit=True,
)
issue.save()
self.assertRaises(SerialNoInventoryDimensionError, issue.submit)
self.assertFalse(
frappe.db.exists(
"Stock Ledger Entry",
{"voucher_no": issue.name, "is_cancelled": 0},
)
)
def test_serial_no_inventory_dimension_with_legacy_inward_sle(self):
item = make_item(
"Test Serialized Legacy Inventory Dimension Item",
{"has_serial_no": 1, "is_stock_item": 1},
)
serial_no = "Test Serialized Legacy Inventory Dimension Serial No"
warehouse = create_warehouse("Serialized Legacy Inventory Dimension Warehouse")
create_inventory_dimension(
apply_to_all_doctypes=1,
dimension_name="Legacy Serial Rack",
reference_document="Rack",
validate_negative_stock=0,
)
receipt = make_stock_entry(
item_code=item.name,
to_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
do_not_submit=True,
)
receipt.items[0].to_legacy_serial_rack = "Rack 2"
receipt.save()
receipt.submit()
frappe.db.set_value(
"Stock Ledger Entry",
{"voucher_no": receipt.name, "actual_qty": (">", 0), "is_cancelled": 0},
{"serial_and_batch_bundle": None, "serial_no": f"Other Legacy Serial, {serial_no}"},
)
issue = make_stock_entry(
item_code=item.name,
from_warehouse=warehouse,
qty=1,
serial_no=serial_no,
use_serial_batch_fields=1,
do_not_submit=True,
)
issue.items[0].legacy_serial_rack = "Rack 1"
issue.save()
self.assertRaises(SerialNoInventoryDimensionError, issue.submit)
def test_validate_negative_stock_with_multiple_dimension(self):
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0)
item_code = "Test Negative Multi Inventory Dimension Item"
@@ -709,13 +899,16 @@ def create_inventory_dimension(**args):
args = frappe._dict(args)
if frappe.db.exists("Inventory Dimension", args.dimension_name):
return frappe.get_doc("Inventory Dimension", args.dimension_name)
doc = frappe.get_doc("Inventory Dimension", args.dimension_name)
else:
doc = frappe.new_doc("Inventory Dimension")
doc.update(args)
doc = frappe.new_doc("Inventory Dimension")
doc.update(args)
if not args.do_not_save:
doc.insert(ignore_permissions=True)
if not args.do_not_save:
doc.insert(ignore_permissions=True)
frappe.local.inventory_dimensions = {}
frappe.local.document_wise_inventory_dimensions = {}
return doc

View File

@@ -290,7 +290,7 @@ class LandedCostVoucher(Document):
# update stock & gl entries for cancelled state of PR
doc.docstatus = 2
doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True)
doc.make_gl_entries_on_cancel()
doc.make_gl_entries_on_cancel(from_repost=True)
# update stock & gl entries for submit state of PR
doc.docstatus = 1

View File

@@ -10,6 +10,7 @@ import json
import frappe
import frappe.defaults
from frappe import _, msgprint
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Order
from frappe.query_builder.functions import Sum
@@ -17,6 +18,7 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_se
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.work_order.work_order import get_item_details
from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.get_item_details import get_price_list_rate_for
@@ -94,6 +96,22 @@ class MaterialRequest(BuyingController):
def check_if_already_pulled(self):
pass
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
{
"Sales Order": {
"ref_dn_field": "sales_order",
"compare_fields": [["company", "="]],
},
"Sales Order Item": {
"ref_dn_field": "sales_order_item",
"compare_fields": [["item_code", "="], ["uom", "="], ["conversion_factor", "="]],
"is_child_table": True,
"allow_duplicate_prev_row_id": True,
},
}
)
def validate_qty_against_so(self):
so_items = {} # Format --> {'SO/00001': {'Item/001': 120, 'Item/002': 24}}
for d in self.get("items"):
@@ -136,6 +154,7 @@ class MaterialRequest(BuyingController):
self.validate_schedule_date()
self.check_for_on_hold_or_closed_status("Sales Order", "sales_order")
self.validate_with_previous_doc()
self.validate_uom_is_integer("uom", "qty")
self.validate_material_request_type()
@@ -482,6 +501,8 @@ def make_purchase_order(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, "material_request_item", "stock_qty")
def postprocess(source, target_doc):
if frappe.flags.args and frappe.flags.args.default_supplier:
# items only for given default supplier
@@ -498,7 +519,7 @@ def make_purchase_order(source_name, target_doc=None, args=None):
filtered_items = args.get("filtered_children", [])
child_filter = d.name in filtered_items if filtered_items else True
qty = d.ordered_qty or d.received_qty
qty = (d.ordered_qty or d.received_qty) + flt(mapped_qty_by_item.get(d.name, 0))
return qty < d.stock_qty and child_filter
@@ -534,7 +555,16 @@ def make_purchase_order(source_name, target_doc=None, args=None):
@frappe.whitelist()
def make_request_for_quotation(source_name, target_doc=None):
def make_request_for_quotation(source_name: str, target_doc: str | dict | Document | None = None):
def update_item(obj, target, source_parent):
qty = obj.ordered_qty or obj.received_qty
target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor
target.stock_qty = target.qty * target.conversion_factor
def select_item(d):
qty = d.ordered_qty or d.received_qty
return qty < d.stock_qty
doclist = get_mapped_doc(
"Material Request",
source_name,
@@ -550,6 +580,8 @@ def make_request_for_quotation(source_name, target_doc=None):
["parent", "material_request"],
["project", "project_name"],
],
"postprocess": update_item,
"condition": select_item,
},
},
target_doc,
@@ -774,6 +806,8 @@ def make_stock_entry(source_name, target_doc=None):
target.fg_completed_qty = job_card_details[0].for_quantity
target.from_bom = 1
target.cap_completed_qty_to_material_coverage()
doclist = get_mapped_doc(
"Material Request",
source_name,

View File

@@ -15,6 +15,7 @@ from erpnext.stock.doctype.material_request.material_request import (
create_pick_list,
make_in_transit_stock_entry,
make_purchase_order,
make_request_for_quotation,
make_stock_entry,
make_supplier_quotation,
raise_work_orders,
@@ -47,6 +48,26 @@ class TestMaterialRequest(FrappeTestCase):
self.assertEqual(po.doctype, "Purchase Order")
self.assertEqual(len(po.get("items")), len(mr.get("items")))
def test_make_request_for_quotation_skips_ordered_items(self):
mr = frappe.copy_doc(test_records[0]).insert()
mr = frappe.get_doc("Material Request", mr.name)
mr.submit()
# fully order the first item, leave the second pending
po = make_purchase_order(mr.name)
po.supplier = "_Test Supplier"
po.schedule_date = today()
po.items = [po.items[0]]
po.items[0].schedule_date = today()
po.insert()
po.submit()
rfq = make_request_for_quotation(mr.name)
self.assertEqual(len(rfq.get("items")), 1)
self.assertEqual(rfq.items[0].material_request_item, mr.items[1].name)
self.assertEqual(rfq.items[0].qty, mr.items[1].qty)
def test_make_supplier_quotation(self):
mr = frappe.copy_doc(test_records[0]).insert()
@@ -919,6 +940,18 @@ class TestMaterialRequest(FrappeTestCase):
self.assertRaises(OverAllowanceError, mr.submit)
def test_item_change_on_sales_order_row_is_blocked(self):
from erpnext.selling.doctype.sales_order.sales_order import make_material_request
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
other_item = create_item("_Test MR Item Swap").name
so = make_sales_order()
mr = make_material_request(so.name)
mr.material_request_type = "Purchase"
# swapping the fetched item would leave a stale link to the SO row
mr.items[0].item_code = other_item
self.assertRaises(frappe.ValidationError, mr.insert)
def test_pending_qty_in_pick_list(self):
"""Test for pick list mapped doc qty from partially received Material Request Transfer"""
import json

View File

@@ -1508,6 +1508,7 @@ def add_product_bundles_to_delivery_note(
@frappe.whitelist()
def create_stock_entry(pick_list: str | dict):
pick_list = frappe.get_doc(frappe.parse_json(pick_list))
pick_list.check_permission("read")
validate_item_locations(pick_list)
stock_entry = frappe.new_doc("Stock Entry")

View File

@@ -1078,6 +1078,58 @@ class TestPickList(FrappeTestCase):
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
def test_get_items_keeps_pick_list_rows_on_stock_entry(self):
"""Entering fg_completed_qty on a Stock Entry mapped from a Pick List triggers get_items();
it must not refetch from the BOM, or the pick_list_item links transferred_qty rides on are
lost and the Pick List stays Open with every row offered again."""
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import (
create_pick_list as pick_list_for_wo,
)
from erpnext.manufacturing.doctype.work_order.work_order import make_work_order
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
source_warehouse = create_warehouse("_Test Partial Transfer Source")
wip_warehouse = create_warehouse("_Test Partial Transfer WIP", company="_Test Company")
fg_warehouse = create_warehouse("_Test Partial Transfer FG", company="_Test Company")
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
bom = make_bom(item=fg_item, rate=100, raw_materials=[rm_item])
make_stock_entry(item=rm_item, to_warehouse=source_warehouse, qty=100)
wo = make_work_order(item=fg_item, qty=10, bom_no=bom.name, company="_Test Company")
wo.required_items[0].source_warehouse = source_warehouse
wo.wip_warehouse = wip_warehouse
wo.fg_warehouse = fg_warehouse
wo.submit()
pick_list = pick_list_for_wo(wo.name, for_qty=wo.qty)
pick_list.save().submit()
self.assertEqual(pick_list.status, "Open")
se = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertTrue(all(row.pick_list_item for row in se.items))
self.assertEqual(se.fg_completed_qty, 0)
se.fg_completed_qty = 4
se.get_items()
self.assertEqual(len(se.items), len(pick_list.locations))
self.assertTrue(all(row.pick_list_item for row in se.items))
se.fg_completed_qty = 0
for row in se.items:
row.qty = 4
se.save().submit()
self.assertEqual(se.fg_completed_qty, 0)
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
next_se = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(len(next_se.items), 1)
self.assertEqual(next_se.items[0].qty, 6)
def test_create_second_delivery_note_with_fully_delivered_location(self):
# When one pick list item is fully delivered by the first Delivery Note
# and another item is still pending, creating a second Delivery Note from

View File

@@ -365,6 +365,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",
@@ -1301,7 +1302,7 @@
"idx": 261,
"is_submittable": 1,
"links": [],
"modified": "2025-11-27 16:46:30.210628",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",

View File

@@ -18,6 +18,7 @@ from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accoun
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.accounts_controller import merge_taxes
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction
from erpnext.stock.serial_batch_bundle import (
SerialBatchCreation,
@@ -1350,6 +1351,8 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
doc = frappe.get_doc("Purchase Receipt", source_name)
returned_qty_map = get_returned_qty_map(source_name)
invoiced_qty_map = get_invoiced_qty_map(source_name)
for ref, qty in get_qty_already_mapped(target_doc, "pr_detail").items():
invoiced_qty_map[ref] = invoiced_qty_map.get(ref, 0) + qty
def set_missing_values(source, target):
if len(target.get("items")) == 0:
@@ -1434,7 +1437,7 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
},
"postprocess": update_item,
"filter": lambda d: (
get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0
get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] >= 0
),
"condition": select_item,
},

View File

@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, call
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, add_to_date, now, nowdate, today
from frappe.utils import add_days, add_to_date, flt, now, nowdate, today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.utils import repost_gle_for_stock_vouchers
@@ -475,6 +475,56 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin):
# incoming rate after reposting should be 150
self.assertSLEs(se, [{"incoming_rate": 150}])
def test_recalculate_stock_entry_additional_cost_updates_all_incoming_rows(self):
from erpnext.stock.stock_ledger import update_entries_after
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
warehouse = "Stores - TCP1"
items = [
self.make_item(f"_Test Repost Addl Cost {x}", {"is_stock_item": 1}).name for x in ("A", "B", "C")
]
for item_code in items:
make_stock_entry(item_code=item_code, target=warehouse, company=company, qty=100, rate=10)
transfer = make_stock_entry(company=company, purpose="Material Transfer", do_not_save=True)
transfer.from_warehouse = warehouse
transfer.to_warehouse = warehouse
transfer.items = []
for item_code in items:
transfer.append(
"items",
{
"item_code": item_code,
"qty": 100,
"s_warehouse": warehouse,
"t_warehouse": warehouse,
"uom": "Nos",
"conversion_factor": 1,
},
)
transfer.append(
"additional_costs",
{
"expense_account": "Expenses Included In Valuation - TCP1",
"description": "freight",
"amount": 100,
},
)
transfer.insert()
transfer.submit()
first_row = transfer.items[0]
frappe.db.set_value("Stock Entry Detail", first_row.name, "basic_rate", first_row.basic_rate + 1)
update_entries_after.recalculate_amounts_in_stock_entry(MagicMock(), transfer.name, first_row.name)
transfer.load_from_db()
detail_additional_cost = sum(row.additional_cost for row in transfer.items)
net_added_to_stock = sum(row.amount - row.basic_amount for row in transfer.items)
self.assertEqual(flt(detail_additional_cost, 2), flt(transfer.total_additional_costs, 2))
self.assertEqual(flt(net_added_to_stock, 2), flt(transfer.total_additional_costs, 2))
def test_repost_multi_line_moving_average_return(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc

View File

@@ -181,6 +181,13 @@ frappe.ui.form.on("Stock Entry", {
if (!check_should_not_attach_bom_items(frm.doc.bom_no)) {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
}
if (frm.doc.pick_list) {
frm.set_df_property("get_items", "hidden", 1);
if (!frm.doc.job_card) {
frm.set_df_property("fg_completed_qty", "read_only", 1);
}
}
},
setup_quality_inspection: function (frm) {
@@ -1356,10 +1363,16 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
) {
frappe.model.remove_from_locals("Work Order", this.frm.doc.work_order);
}
if (this.frm.doc.pick_list) {
frappe.model.remove_from_locals("Pick List", this.frm.doc.pick_list);
}
}
fg_completed_qty() {
this.get_items();
if (!this.frm.doc.pick_list) {
this.get_items();
}
}
get_items() {

View File

@@ -1205,17 +1205,26 @@ class StockEntry(StockController):
if transfer_limit_qty < to_transfer_qty:
return
required_qty, transferred_qty = self._get_work_order_material_qty()
self.cap_completed_qty_to_material_coverage()
def cap_completed_qty_to_material_coverage(self):
required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data()
if not required_qty:
return
covered_before = self._get_covered_work_order_qty(required_qty, transferred_qty)
covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
for row in self.items:
item_code = row.original_item or row.item_code
if row.s_warehouse and item_code in required_qty:
transferred_qty[item_code] += flt(row.qty) * flt(row.conversion_factor or 1)
if self.job_card:
material_reference = row.job_card_item
transferred = flt(row.qty)
else:
material_reference = row.original_item or row.item_code
transferred = flt(row.qty) * flt(row.conversion_factor or 1)
covered_after = self._get_covered_work_order_qty(required_qty, transferred_qty)
if material_reference in required_qty and (self.job_card or row.s_warehouse):
transferred_qty[material_reference] += transferred
covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty"))
self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry)
@@ -1230,6 +1239,49 @@ class StockEntry(StockController):
return False
return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card")
def _get_material_coverage_data(self):
if self.job_card:
return self._get_job_card_material_qty()
return self._get_work_order_material_qty()
def _get_job_card_material_qty(self):
job_card = frappe.get_doc("Job Card", self.job_card)
required_qty = {}
transferred_qty = {}
for row in job_card.items:
if flt(row.required_qty) <= 0:
continue
required_qty[row.name] = flt(row.required_qty)
transferred_qty[row.name] = flt(row.transferred_qty)
return (
required_qty,
transferred_qty,
self._get_job_card_target_qty(job_card),
job_card.precision("required_qty", "items"),
)
def _get_job_card_target_qty(self, job_card):
required_by_item = {}
for row in job_card.items:
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
work_order_required_by_item = {}
work_order = frappe.get_doc("Work Order", job_card.work_order)
for row in work_order.required_items:
if job_card.operation != row.operation:
continue
work_order_required_by_item[row.item_code] = work_order_required_by_item.get(
row.item_code, 0.0
) + flt(row.required_qty)
target_qty = [
item_required * flt(work_order.qty) / work_order_required_by_item[item_code]
for item_code, item_required in required_by_item.items()
if work_order_required_by_item.get(item_code)
]
return min(target_qty) if target_qty else job_card.for_quantity
def _get_work_order_material_qty(self):
required_qty = {}
transferred_qty = {}
@@ -1241,15 +1293,20 @@ class StockEntry(StockController):
transferred_qty[row.item_code] = max(
transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty)
)
return required_qty, transferred_qty
return (
required_qty,
transferred_qty,
self.pro_doc.qty,
self.pro_doc.precision("required_qty", "required_items"),
)
def _get_covered_work_order_qty(self, required_qty, transferred_qty):
def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision):
min_fraction = get_minimum_material_coverage_fraction(
required_qty,
transferred_qty,
self.pro_doc.precision("required_qty", "required_items"),
precision,
)
return min_fraction * flt(self.pro_doc.qty)
return min_fraction * flt(target_qty)
def _validate_no_excess_transfer(self):
if self.is_return:
@@ -2576,6 +2633,9 @@ class StockEntry(StockController):
@frappe.whitelist()
def get_items(self):
if self.pick_list:
return
self.set("items", [])
self.validate_work_order()

View File

@@ -2,19 +2,21 @@
# License: GNU General Public License v3. See license.txt
import re
from datetime import date
import frappe
from frappe import _, bold
from frappe.core.doctype.role.role import get_users
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Concat_ws, Sum
from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
from erpnext.accounts.utils import get_fiscal_year
from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.serial_batch_bundle import SerialBatchBundle
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_parsed_serial_nos
from erpnext.stock.serial_batch_bundle import SerialBatchBundle, get_serial_nos
from erpnext.stock.stock_ledger import get_previous_sle
@@ -30,6 +32,10 @@ class InventoryDimensionNegativeStockError(frappe.ValidationError):
pass
class SerialNoInventoryDimensionError(frappe.ValidationError):
pass
exclude_from_linked_with = True
@@ -98,6 +104,7 @@ class StockLedgerEntry(Document):
self.block_transactions_against_group_warehouse()
self.validate_with_last_transaction_posting_time()
self.validate_inventory_dimension_negative_stock()
self.validate_serial_no_inventory_dimension()
def set_posting_datetime(self):
from erpnext.stock.utils import get_combine_datetime
@@ -172,6 +179,87 @@ class StockLedgerEntry(Document):
return inv_dimension_dict
def validate_serial_no_inventory_dimension(self):
if self.is_cancelled or self.actual_qty >= 0:
return
dimensions = get_inventory_dimensions()
if not dimensions:
return
serial_nos = get_serial_nos(self.serial_and_batch_bundle)
if not serial_nos and self.serial_no:
serial_nos = get_parsed_serial_nos(self.serial_no)
if not serial_nos:
return
for serial_no, values in self.get_last_inward_dimensions(serial_nos, dimensions).items():
mismatches = []
for dimension in dimensions:
fieldname = dimension.fieldname
expected_value = values.get(fieldname)
if expected_value != self.get(fieldname):
mismatches.append(
_('{0}: expected "{1}", got "{2}"').format(
dimension.dimension_name,
expected_value or _("Not Set"),
self.get(fieldname),
)
)
if mismatches:
frappe.throw(
_("Serial No {0} is not available in the selected inventory dimensions: {1}").format(
frappe.bold(serial_no), frappe.bold(", ".join(mismatches))
),
title=_("Incorrect Inventory Dimension"),
exc=SerialNoInventoryDimensionError,
)
def get_last_inward_dimensions(self, serial_nos, dimensions):
sle = frappe.qb.DocType("Stock Ledger Entry")
serial_entry = frappe.qb.DocType("Serial and Batch Entry")
dimension_fields = [sle[dimension.fieldname].as_(dimension.fieldname) for dimension in dimensions]
escaped_serial_nos = [re.escape(serial_no) for serial_no in serial_nos]
legacy_serial_pattern = r"[\n,][[:space:]]*(" + "|".join(escaped_serial_nos) + r")[[:space:]]*[\n,]"
legacy_serial_condition = (
sle.serial_and_batch_bundle.isnull() | (sle.serial_and_batch_bundle == "")
) & Concat_ws("", "\n", sle.serial_no, "\n").regexp(legacy_serial_pattern)
rows = (
frappe.qb.from_(sle)
.left_join(serial_entry)
.on(serial_entry.parent == sle.serial_and_batch_bundle)
.select(
serial_entry.serial_no.as_("bundle_serial_no"),
sle.serial_no.as_("legacy_serial_nos"),
*dimension_fields,
)
.where(
(serial_entry.serial_no.isin(serial_nos) | legacy_serial_condition)
& (sle.item_code == self.item_code)
& (sle.actual_qty > 0)
& (sle.is_cancelled == 0)
& (sle.posting_datetime <= self.posting_datetime)
)
.orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
).run(as_dict=True)
serial_nos = set(serial_nos)
last_inward_dimensions = {}
for row in rows:
row_serial_nos = (
[row.bundle_serial_no]
if row.bundle_serial_no
else get_parsed_serial_nos(row.legacy_serial_nos)
)
for serial_no in serial_nos.intersection(row_serial_nos):
last_inward_dimensions.setdefault(serial_no, row)
return last_inward_dimensions
def on_submit(self):
self.check_stock_frozen_date()

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _, throw
from frappe.model import child_table_fields, default_fields
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import IfNull, Sum
@@ -34,11 +35,34 @@ purchase_doctypes = [
"Purchase Invoice",
]
maintain_same_rate_source_fields = {
"Purchase Order": {"supplier_quotation_item": "Supplier Quotation Item"},
"Purchase Receipt": {"purchase_order_item": "Purchase Order Item"},
"Purchase Invoice": {"po_detail": "Purchase Order Item", "pr_detail": "Purchase Receipt Item"},
"Sales Order": {"quotation_item": "Quotation Item"},
"Delivery Note": {"so_detail": "Sales Order Item", "si_detail": "Sales Invoice Item"},
"Sales Invoice": {"so_detail": "Sales Order Item", "dn_detail": "Delivery Note Item"},
}
LOCKED_RATE_FIELDS = [
"price_list_rate",
"rate",
"discount_percentage",
"discount_amount",
"margin_type",
"margin_rate_or_amount",
]
NOT_APPLICABLE_TAX = "N/A"
@frappe.whitelist()
def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True):
def get_item_details(
args: dict | str,
doc: Document | dict | str | None = None,
for_validate: bool | str = False,
overwrite_warehouse: bool | str = True,
):
"""
args = {
"item_code": "",
@@ -100,16 +124,20 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
if args.get("doctype") in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]:
args.customer = None
out.update(get_price_list_rate(args, item))
source_row = get_rate_locked_source_row(args, doc)
if source_row:
lock_source_rate(out, source_row)
else:
out.update(get_price_list_rate(args, item))
if (
not out.price_list_rate
and args.transaction_type == "selling"
and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list")
):
fallback_args = args.copy()
fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list")
out.update(get_price_list_rate(fallback_args, item))
if (
not out.price_list_rate
and args.transaction_type == "selling"
and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list")
):
fallback_args = args.copy()
fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list")
out.update(get_price_list_rate(fallback_args, item))
args.customer = current_customer
@@ -124,9 +152,8 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
if args.get(key) is None:
args[key] = value
data = get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate)
out.update(data)
if not source_row:
out.update(get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate))
if (
frappe.db.get_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward")
@@ -156,6 +183,52 @@ def remove_standard_fields(details):
return details
def get_rate_locked_source_row(args, doc):
"""Reads the source row from the DB, not the mutable target row, so an unsaved edit can't override the locked rate."""
if isinstance(doc, str):
doc = json.loads(doc)
source_fields = maintain_same_rate_source_fields.get(args.parenttype or args.doctype)
if not source_fields or not doc or args.get("is_return") or not maintain_same_rate_enabled(args):
return None
row = next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None)
if not row:
return None
for link_field, source_doctype in source_fields.items():
if source_name := row.get(link_field):
# don't leak another document's pricing to a caller without read access
source = frappe.db.get_value(
source_doctype, source_name, [*LOCKED_RATE_FIELDS, "parent", "parenttype"], as_dict=True
)
if source and frappe.has_permission(source.parenttype, doc=source.parent):
return source
return None
return None
def maintain_same_rate_enabled(transaction_args):
if (transaction_args.parenttype or transaction_args.doctype) in purchase_doctypes:
if transaction_args.get("is_internal_supplier"):
return False
return bool(cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate")))
if transaction_args.get("is_internal_customer"):
return False
return bool(cint(frappe.get_cached_value("Selling Settings", "None", "maintain_same_sales_rate")))
def lock_source_rate(out, source_row):
"""Copies the full pricing block so a manual discount or margin on the source row survives."""
out.price_list_rate = flt(source_row.get("price_list_rate")) or flt(source_row.get("rate"))
out.rate = flt(source_row.get("rate"))
out.discount_percentage = flt(source_row.get("discount_percentage"))
out.discount_amount = flt(source_row.get("discount_amount"))
out.margin_type = source_row.get("margin_type")
out.margin_rate_or_amount = flt(source_row.get("margin_rate_or_amount"))
def set_valuation_rate(out, args):
if frappe.db.exists("Product Bundle", {"name": args.item_code, "disabled": 0}, cache=True):
valuation_rate = 0.0
@@ -1345,6 +1418,11 @@ def get_pos_profile(company, pos_profile=None, user=None):
if not user:
user = frappe.session["user"]
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
if not allowed_pos_profiles:
return None
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
@@ -1354,6 +1432,7 @@ def get_pos_profile(company, pos_profile=None, user=None):
.on(pf.name == pfu.parent)
.select(pf.star)
.where((pfu.user == user) & (pfu.default == 1))
.where(pf.name.isin(allowed_pos_profiles))
)
if company:
@@ -1368,6 +1447,7 @@ def get_pos_profile(company, pos_profile=None, user=None):
.on(pf.name == pfu.parent)
.select(pf.star)
.where((pf.company == company) & (pf.disabled == 0))
.where(pf.name.isin(allowed_pos_profiles))
).run(as_dict=True)
return pos_profile and pos_profile[0] or None
@@ -1522,13 +1602,21 @@ def apply_price_list(args, as_doc=False, doc=None):
def apply_price_list_on_item(args, doc=None):
item_doc = frappe.db.get_value("Item", args.item_code, ["name", "variant_of"], as_dict=1)
item_details = get_price_list_rate(args, item_doc)
source_row = get_rate_locked_source_row(args, doc)
if source_row:
item_details = frappe._dict()
lock_source_rate(item_details, source_row)
else:
item_details = get_price_list_rate(args, item_doc)
args.conversion_factor = flt(args.conversion_factor) or get_conversion_factor(
args.item_code, args.uom
).get("conversion_factor", 1)
args.stock_qty = flt(args.qty) * flt(args.conversion_factor)
item_details.update(get_pricing_rule_for_item(args, doc=doc))
if not source_row:
item_details.update(get_pricing_rule_for_item(args, doc=doc))
return item_details

View File

@@ -77,4 +77,4 @@ frappe.query_reports["Available Serial No"] = {
},
};
erpnext.utils.add_inventory_dimensions("Balance Serial No", 10);
erpnext.utils.add_inventory_dimensions("Available Serial No", 10);

View File

@@ -9,9 +9,9 @@
"idx": 0,
"is_standard": "Yes",
"json": "{}",
"letter_head": "Test",
"letter_head": null,
"letterhead": null,
"modified": "2025-02-03 15:39:47.613040",
"modified": "2026-08-26 14:39:19.102191",
"modified_by": "Administrator",
"module": "Stock",
"name": "Incorrect Serial and Batch Bundle",

View File

@@ -1406,12 +1406,14 @@ class update_entries_after:
stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True)
stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False)
stock_entry.db_update()
update_additional_cost_rows = bool(stock_entry.get("additional_costs"))
for d in stock_entry.items:
# Update only the row that matches the voucher_detail_no or the row containing the FG/Scrap Item.
# Additional costs are redistributed across all incoming rows.
if (
d.name == voucher_detail_no
or (not d.s_warehouse and d.t_warehouse)
or stock_entry.purpose in ["Manufacture", "Repack"]
or (update_additional_cost_rows and d.t_warehouse)
):
d.db_update()
@@ -1750,6 +1752,30 @@ class update_entries_after:
frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True)
self.reset_bin_without_stock_ledger_entries()
def reset_bin_without_stock_ledger_entries(self):
"""Reset the bin when its ledger has no entries left, a repost never covers that case."""
item_code, warehouse = self.args.get("item_code"), self.args.get("warehouse")
if not item_code or not warehouse or (item_code, warehouse) in self.prev_sle_dict:
return
if frappe.db.exists(
"Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}
):
return
bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
if not bin_name:
return
frappe.db.set_value(
"Bin",
bin_name,
{"actual_qty": 0.0, "stock_value": 0.0, "valuation_rate": 0.0},
update_modified=True,
)
def get_sle_against_current_voucher(kwargs):
kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time)
@@ -1866,9 +1892,6 @@ def get_stock_ledger_entries(
else:
conditions += " and warehouse = %(warehouse)s"
elif previous_sle.get("warehouse_condition"):
conditions += " and " + previous_sle.get("warehouse_condition")
if check_serial_no and previous_sle.get("serial_no"):
# conditions += " and serial_no like {}".format(frappe.db.escape('%{0}%'.format(previous_sle.get("serial_no"))))
serial_no = previous_sle.get("serial_no")

View File

@@ -177,3 +177,248 @@ class TestGetItemDetail(FrappeTestCase):
dn.save()
self.assertEqual(dn.items[0].batch_no, "BATCH01")
self.assertEqual(dn.items[0].rate, 50)
def test_maintain_same_rate_keeps_source_rate_on_refetch(self):
"""#57436: with "maintain same rate" on, re-fetching a PR row mapped from a
PO must keep the PO rate instead of pulling a newer, higher Item Price.
The rate is validated on save, so it can never persist changed; assert the
fetched rate directly to prove the newer Item Price is never picked up.
"""
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.doctype.item.test_item import make_item
def set_maintain_same_rate(value):
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", value)
frappe.clear_cache(doctype="Buying Settings")
set_maintain_same_rate(1)
item_code = make_item(properties={"is_stock_item": 1}).name
po = create_purchase_order(item_code=item_code, qty=1, rate=100)
# The PO may auto-insert an Item Price at 100; bump it to the newer, higher rate.
item_price = frappe.db.get_value(
"Item Price", {"item_code": item_code, "price_list": "Standard Buying"}
)
if item_price:
frappe.db.set_value("Item Price", item_price, "price_list_rate", 120)
else:
frappe.get_doc(
{
"doctype": "Item Price",
"price_list": "Standard Buying",
"item_code": item_code,
"price_list_rate": 120,
}
).insert()
pr = make_purchase_receipt(po.name)
pr.insert()
def fetch_price_list_rate():
args = frappe._dict(
{
"item_code": item_code,
"doctype": "Purchase Receipt",
"name": pr.name,
"company": pr.company,
"supplier": pr.supplier,
"currency": pr.currency,
"conversion_rate": 1.0,
"price_list": "Standard Buying",
"price_list_currency": pr.currency,
"plc_conversion_rate": 1.0,
"warehouse": pr.items[0].warehouse,
"uom": pr.items[0].uom,
"stock_uom": pr.items[0].stock_uom,
"qty": pr.items[0].qty,
"child_doctype": pr.items[0].doctype,
"child_docname": pr.items[0].name,
"is_return": 0,
"is_internal_supplier": 0,
"ignore_pricing_rule": 1,
}
)
return get_item_details(args, pr).get("price_list_rate")
# Rate stays at the PO rate; the newer Item Price (120) is not fetched.
self.assertEqual(fetch_price_list_rate(), 100)
# Control: without the setting the newer Item Price would be fetched.
set_maintain_same_rate(0)
self.assertEqual(fetch_price_list_rate(), 120)
def test_apply_price_list_keeps_source_rate_when_maintain_same_rate(self):
"""#57436: the bulk apply_price_list path (price list / party / conversion rate
change) must also keep the source rate on mapped rows, not just re-fetch of a
single row. Here a PR row carries its PO rate (175) while the current price list
rate is 100; the bulk apply must keep 175.
"""
from frappe.utils import flt, nowdate
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.get_item_details import apply_price_list
item_code = "_Test Item"
price_list = "_Test Buying Price List"
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
frappe.clear_cache(doctype="Buying Settings")
try:
po = create_purchase_order(item_code=item_code, rate=175, qty=1)
row_name = "pr-row-1"
pr_doc = {
"doctype": "Purchase Receipt",
"items": [
{
"name": row_name,
"item_code": item_code,
"purchase_order_item": po.items[0].name,
"price_list_rate": 175,
"rate": 175,
}
],
}
args = frappe._dict(
doctype="Purchase Receipt",
supplier=po.supplier,
company=po.company,
currency=po.currency,
conversion_rate=1.0,
price_list=price_list,
plc_conversion_rate=1.0,
transaction_date=nowdate(),
items=[
frappe._dict(
doctype="Purchase Receipt Item",
parenttype="Purchase Receipt",
item_code=item_code,
child_docname=row_name,
qty=1,
uom=po.items[0].uom,
stock_uom=po.items[0].stock_uom,
conversion_factor=1.0,
)
],
)
result = apply_price_list(args, doc=pr_doc)
self.assertEqual(flt(result["children"][0].get("price_list_rate")), 175)
finally:
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
frappe.clear_cache(doctype="Buying Settings")
def test_maintain_same_rate_keeps_source_discount_on_refetch(self):
"""A mapped source row with a discount has rate != price_list_rate. Re-fetch must
return the source's rate and discount, not just the pre-discount price, or the
recomputed rate diverges from the reference and fails maintain-same-rate on save.
"""
from frappe.utils import flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
item_code = "_Test Item"
price_list = "_Test Buying Price List"
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
frappe.clear_cache(doctype="Buying Settings")
try:
# source PO carries the discount: list rate 100, 10% off, effective rate 90
frappe.flags.dont_fetch_price_list_rate = True
po = create_purchase_order(item_code=item_code, qty=1, do_not_save=True)
po.buying_price_list = price_list
po.items[0].price_list_rate = 100
po.items[0].discount_percentage = 10
po.items[0].rate = 90
po.insert()
po.submit()
frappe.flags.dont_fetch_price_list_rate = False
row_name = "pr-row-1"
pr_doc = {
"doctype": "Purchase Receipt",
"items": [
{"name": row_name, "item_code": item_code, "purchase_order_item": po.items[0].name}
],
}
args = frappe._dict(
item_code=item_code,
doctype="Purchase Receipt",
company=po.company,
supplier=po.supplier,
currency=po.currency,
conversion_rate=1.0,
price_list=price_list,
price_list_currency=po.currency,
plc_conversion_rate=1.0,
warehouse="_Test Warehouse - _TC",
uom=po.items[0].uom,
stock_uom=po.items[0].stock_uom,
qty=1,
child_docname=row_name,
is_return=0,
is_internal_supplier=0,
ignore_pricing_rule=1,
)
out = get_item_details(args, pr_doc)
self.assertEqual(flt(out.get("price_list_rate")), 100)
self.assertEqual(flt(out.get("rate")), 90)
self.assertEqual(flt(out.get("discount_percentage")), 10)
finally:
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
frappe.clear_cache(doctype="Buying Settings")
frappe.flags.dont_fetch_price_list_rate = False
def test_rate_lock_source_lookup_checks_permission(self):
"""The lock reads source pricing via a direct DB read, so it must not disclose a
source document's pricing to a caller who cannot read that document.
"""
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.get_item_details import get_rate_locked_source_row
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
frappe.clear_cache(doctype="Buying Settings")
role, email = "_Test Role Without PO Access", "_test_rate_lock_probe@example.com"
try:
po = create_purchase_order(item_code="_Test Item", qty=1, rate=90)
pr_doc = {
"doctype": "Purchase Receipt",
"items": [{"name": "r1", "item_code": "_Test Item", "purchase_order_item": po.items[0].name}],
}
args = frappe._dict(doctype="Purchase Receipt", child_docname="r1")
# an authorized caller receives the source row
self.assertIsNotNone(get_rate_locked_source_row(args.copy(), dict(pr_doc)))
if not frappe.db.exists("Role", role):
frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert(
ignore_permissions=True
)
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "Probe",
"send_welcome_email": 0,
"roles": [{"role": role}],
}
).insert(ignore_permissions=True)
frappe.set_user(email)
# a caller who cannot read the Purchase Order gets nothing
self.assertIsNone(get_rate_locked_source_row(args.copy(), dict(pr_doc)))
finally:
frappe.set_user("Administrator")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
frappe.clear_cache(doctype="Buying Settings")

View File

@@ -50,6 +50,7 @@ def transaction_processing(data, from_doctype, to_doctype):
@frappe.whitelist()
def retry(date: str | None = None):
frappe.only_for("System Manager")
if not date:
date = today()