Compare commits

..

199 Commits

Author SHA1 Message Date
Frappe PR Bot
0b50853985 chore(release): Bumped to Version 16.34.1
## [16.34.1](https://github.com/frappe/erpnext/compare/v16.34.0...v16.34.1) (2026-09-02)

### Bug Fixes

* filter cancelled BOMs in BOM Stock Analysis (backport [#58647](https://github.com/frappe/erpnext/issues/58647)) ([#58693](https://github.com/frappe/erpnext/issues/58693)) ([d6f9dde](d6f9dde3cb))
* improve message formatting and translation for validation issues ([#58425](https://github.com/frappe/erpnext/issues/58425)) ([a43de7c](a43de7ce95))
* include payment deductions in sales/purchase register ledger bal… (backport [#58437](https://github.com/frappe/erpnext/issues/58437)) ([#58680](https://github.com/frappe/erpnext/issues/58680)) ([4137401](41374019ba))
2026-09-02 12:24:04 +00:00
Sagar Vora
c19ddf187e Merge pull request #58707 from frappe/version-16-hotfix
chore: release v16
2026-09-02 17:51:22 +05:30
Sagar Vora
5f13a04633 Merge pull request #58704 from frappe/mergify/bp/version-16-hotfix/pr-58697
fix!: improve validation in financial report template (backport #58697)
2026-09-02 17:41:50 +05:30
Sagar Vora
7aad59b129 fix!: improve validation in financial report template 2026-09-02 17:31:36 +05:30
Sagar Vora
7c61dfe3e0 Merge pull request #58487 from frappe/mergify/bp/version-16-hotfix/pr-58425
fix: improve message formatting and translation for validation issues (backport #58425)
2026-09-02 17:24:03 +05:30
mergify[bot]
41374019ba fix: include payment deductions in sales/purchase register ledger bal… (backport #58437) (#58680) 2026-09-02 17:00:42 +05:30
mergify[bot]
d6f9dde3cb fix: filter cancelled BOMs in BOM Stock Analysis (backport #58647) (#58693) 2026-09-02 15:28:17 +05:30
Frappe PR Bot
80c2c9e0e0 chore(release): Bumped to Version 16.34.0
# [16.34.0](https://github.com/frappe/erpnext/compare/v16.33.0...v16.34.0) (2026-09-02)

### Bug Fixes

* **accounts:** add permission checks on payment entry whitelisted methods ([#58644](https://github.com/frappe/erpnext/issues/58644)) ([c745558](c745558168))
* **accounts:** added permission checks on `get_available_payment_schedules` (backport [#58588](https://github.com/frappe/erpnext/issues/58588)) ([#58595](https://github.com/frappe/erpnext/issues/58595)) ([765a773](765a7736ed))
* **accounts:** guard get_gl_dict for v16 and drop unused base_gl_composer ([abf311e](abf311e8ca))
* **accounts:** prevent child table doctypes as accounting dimensions ([6dbaa50](6dbaa5048e))
* **accounts:** resolve subscription plans for any reference doctype in Payment Request (backport [#58438](https://github.com/frappe/erpnext/issues/58438)) ([#58450](https://github.com/frappe/erpnext/issues/58450)) ([3c63fff](3c63fffcb6))
* **accounts:** set pos profile on invoices respecting user permissions (backport [#58508](https://github.com/frappe/erpnext/issues/58508)) ([#58519](https://github.com/frappe/erpnext/issues/58519)) ([23290a6](23290a60da))
* add missing perm checks on various whitelisted methods (backport [#58652](https://github.com/frappe/erpnext/issues/58652)) ([#58654](https://github.com/frappe/erpnext/issues/58654)) ([86e9480](86e9480362))
* add type hints to make_request_for_quotation ([4435f16](4435f166ea))
* clarify duplicate internal party messages ([#58469](https://github.com/frappe/erpnext/issues/58469)) ([f0e603a](f0e603aec5))
* **company:** throw if linked to demo_company field ([9f3dd7e](9f3dd7eabd))
* correct is_frozen description on customer and supplier ([#58168](https://github.com/frappe/erpnext/issues/58168)) ([9e00888](9e00888d67))
* **crm:** add missing permission validation on `get_contract_template` (backport [#58621](https://github.com/frappe/erpnext/issues/58621)) ([#58623](https://github.com/frappe/erpnext/issues/58623)) ([30f0cd9](30f0cd9c95))
* **crm:** check write permission in edit_note ([c2261f6](c2261f6129))
* **crm:** validate contact email before saving an email campaign (backport [#58667](https://github.com/frappe/erpnext/issues/58667)) ([#58672](https://github.com/frappe/erpnext/issues/58672)) ([f22f84a](f22f84a58a))
* do not map the same row twice in "Get Items From" (backport [#58617](https://github.com/frappe/erpnext/issues/58617)) ([#58619](https://github.com/frappe/erpnext/issues/58619)) ([f3379b4](f3379b4694))
* explain missing fields when loading party details or taxes (backport [#58627](https://github.com/frappe/erpnext/issues/58627)) ([#58628](https://github.com/frappe/erpnext/issues/58628)) ([8a8bafd](8a8bafd6cb))
* filter fully ordered items when creating RFQ from Material Request ([9b1d907](9b1d907b6a))
* handle BOM price list currency update (backport [#58638](https://github.com/frappe/erpnext/issues/58638)) ([#58639](https://github.com/frappe/erpnext/issues/58639)) ([fb74a03](fb74a030de))
* **manufacturing:** account for pending job card qty ([#58466](https://github.com/frappe/erpnext/issues/58466)) ([7b9cdb4](7b9cdb4926))
* **manufacturing:** classify MRP items without a BOM as Purchase (backport [#58509](https://github.com/frappe/erpnext/issues/58509)) ([#58513](https://github.com/frappe/erpnext/issues/58513)) ([ee3b224](ee3b22403d))
* **manufacturing:** handle duplicate root BOM items (backport [#58614](https://github.com/frappe/erpnext/issues/58614)) ([#58616](https://github.com/frappe/erpnext/issues/58616)) ([22755c4](22755c4ad6))
* **manufacturing:** preserve job card qty in mr transfer (backport [#58482](https://github.com/frappe/erpnext/issues/58482)) ([#58499](https://github.com/frappe/erpnext/issues/58499)) ([76d0cb3](76d0cb382d))
* **manufacturing:** use item warehouses in production plan work orders ([#58663](https://github.com/frappe/erpnext/issues/58663)) ([ec392a7](ec392a7bcf))
* **manufacturing:** use packed row delivery date ([#58570](https://github.com/frappe/erpnext/issues/58570)) ([e64fddf](e64fddfad5))
* persist redistributed additional costs during stock entry repost (backport [#58433](https://github.com/frappe/erpnext/issues/58433)) ([#58533](https://github.com/frappe/erpnext/issues/58533)) ([629bc20](629bc20e90))
* **pos:** add permission checks on `get_invoices` (backport [#58591](https://github.com/frappe/erpnext/issues/58591)) ([#58597](https://github.com/frappe/erpnext/issues/58597)) ([d95eeb0](d95eeb0f6a))
* **projects:** check read permission on source in create_duplicate_project ([bd35957](bd35957582))
* **projects:** ignore cancelled invoices in timesheet portal (backport [#58501](https://github.com/frappe/erpnext/issues/58501)) ([#58504](https://github.com/frappe/erpnext/issues/58504)) ([d732d06](d732d06e25))
* round party balances to currency precision in Trial Balance for Party ([956a6ed](956a6ed407))
* **selling:** check quotation write permission before marking lost ([#58493](https://github.com/frappe/erpnext/issues/58493)) ([4e74831](4e74831a08))
* **selling:** check sales order permission before work order creation (backport [#58492](https://github.com/frappe/erpnext/issues/58492)) ([#58497](https://github.com/frappe/erpnext/issues/58497)) ([2b8e12e](2b8e12ef44))
* **sms_settings:** add patch to pre-fill roles into SMS Settings Roles Table ([4b89ddf](4b89ddffde))
* **stock:** auto-select batch no before saving transaction records (backport [#58536](https://github.com/frappe/erpnext/issues/58536)) ([#58539](https://github.com/frappe/erpnext/issues/58539)) ([eb4c327](eb4c327a2b))
* **stock:** ignore cancelled batch entries in valuation (backport [#58659](https://github.com/frappe/erpnext/issues/58659)) ([#58660](https://github.com/frappe/erpnext/issues/58660)) ([f4ed4ca](f4ed4cae64))
* **stock:** keep pick list links when refetching stock entry items ([01e0844](01e0844d40))
* **stock:** load available serial no report (backport [#58558](https://github.com/frappe/erpnext/issues/58558)) ([#58562](https://github.com/frappe/erpnext/issues/58562)) ([06758f2](06758f2792))
* **stock:** preserve exchange gain loss journals in lcv ([#58505](https://github.com/frappe/erpnext/issues/58505)) ([1242316](124231681a))
* **stock:** validate serial inventory dimensions ([#58394](https://github.com/frappe/erpnext/issues/58394)) ([a1aabaf](a1aabaff18))
* sum item and its alternate transferred qty on work order required items ([1411835](1411835507))
* sync translations from crowdin (version-16-hotfix) ([#58455](https://github.com/frappe/erpnext/issues/58455)) ([c924de3](c924de3c55))
* **taxes:** skip tax addition for invoice created from opening invoice tool ([bfbc13d](bfbc13d147))
* **timesheet:** scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport [#58267](https://github.com/frappe/erpnext/issues/58267)) ([#58577](https://github.com/frappe/erpnext/issues/58577)) ([4f409e5](4f409e5ccf))
* translate doctype in map msg (backport [#58515](https://github.com/frappe/erpnext/issues/58515)) ([#58517](https://github.com/frappe/erpnext/issues/58517)) ([f0d2d40](f0d2d40d35))
* translate label in party validation (backport [#58624](https://github.com/frappe/erpnext/issues/58624)) ([#58626](https://github.com/frappe/erpnext/issues/58626)) ([9bb8d4f](9bb8d4ff93))
* validate items against source Sales Order in Material Request ([6546646](65466464df))
* validate POS Settings invoice and search fields on the server (backport [#58611](https://github.com/frappe/erpnext/issues/58611)) ([#58620](https://github.com/frappe/erpnext/issues/58620)) ([f8c2927](f8c29274a5))
* Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows ([#58526](https://github.com/frappe/erpnext/issues/58526)) ([cb4ec72](cb4ec72370))

### Features

* **analytics:** filter sales and purchase analytics by entity ([#58402](https://github.com/frappe/erpnext/issues/58402)) ([655ed81](655ed81575))
* **manufacturing:** add multi-currency support to Blanket Orders (backport [#58472](https://github.com/frappe/erpnext/issues/58472)) ([#58630](https://github.com/frappe/erpnext/issues/58630)) ([ff656b9](ff656b9ad2))
* valuation type for BOM secondary items (backport [#58431](https://github.com/frappe/erpnext/issues/58431)) ([#58632](https://github.com/frappe/erpnext/issues/58632)) ([1b44df4](1b44df480f))
2026-09-02 07:13:39 +00:00
Diptanil Saha
600cbf36af Merge pull request #58645 from frappe/version-16-hotfix
chore: release v16
2026-09-02 12:42:03 +05:30
mergify[bot]
3c63fffcb6 fix(accounts): resolve subscription plans for any reference doctype in Payment Request (backport #58438) (#58450)
Co-authored-by: jatin3128 <jatinsarna64@gmail.com>
2026-09-02 12:18:17 +05:30
Sudharsanan Ashok
d3b8324819 Merge pull request #58665 from frappe/mergify/bp/version-16-hotfix/pr-58663
fix(manufacturing): use item warehouses in production plan work orders (backport #58663)
2026-09-02 12:05:04 +05:30
Sudharsanan Ashok
46db04ecf8 Merge pull request #58675 from frappe/mergify/bp/version-16-hotfix/pr-58505
fix(stock): preserve exchange gain loss journals in lcv (backport #58505)
2026-09-02 12:03:51 +05:30
Shllokkk
75356265be Merge pull request #58535 from Shllokkk/mr-rfq-filter-pending-qty-v16
fix: filter fully ordered items when creating RFQ from Material Request
2026-09-02 11:46:07 +05:30
Pandiyan P
124231681a fix(stock): preserve exchange gain loss journals in lcv (#58505)
(cherry picked from commit 2b54a581f8)
2026-09-02 06:15:05 +00:00
mergify[bot]
f22f84a58a fix(crm): validate contact email before saving an email campaign (backport #58667) (#58672)
Co-authored-by: kaulith <64089478+kaulith@users.noreply.github.com>
2026-09-02 11:42:32 +05:30
Shllokkk
5af60cf2e3 test: set schedule date on PO before insert 2026-09-02 11:33:03 +05:30
Shllokkk
4435f166ea fix: add type hints to make_request_for_quotation 2026-09-02 11:33:03 +05:30
Shllokkk
5adcb28b3c test: RFQ from Material Request skips fully ordered items 2026-09-02 11:33:02 +05:30
Shllokkk
9b1d907b6a fix: filter fully ordered items when creating RFQ from Material Request 2026-09-02 11:33:02 +05:30
pandiyan
ec392a7bcf fix(manufacturing): use item warehouses in production plan work orders (#58663)
(cherry picked from commit e74ab38eeb)

# Conflicts:
#	erpnext/manufacturing/doctype/production_plan/services/work_order_planning.py
2026-09-02 10:36:32 +05:30
mergify[bot]
f4ed4cae64 fix(stock): ignore cancelled batch entries in valuation (backport #58659) (#58660)
fix(stock): ignore cancelled batch entries in valuation (#58659)

(cherry picked from commit a9b857bdfe)

# Conflicts:
#	erpnext/stock/serial_batch_bundle.py

Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-09-01 16:08:38 +00:00
mergify[bot]
2b8e12ef44 fix(selling): check sales order permission before work order creation (backport #58492) (#58497)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-09-01 21:30:33 +05:30
mergify[bot]
86e9480362 fix: add missing perm checks on various whitelisted methods (backport #58652) (#58654)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-01 14:01:40 +00:00
mergify[bot]
4f409e5ccf fix(timesheet): scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport #58267) (#58577)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-01 12:09:00 +00:00
Shllokkk
01fecfe093 Merge pull request #58649 from frappe/mergify/bp/version-16-hotfix/pr-58607
fix: round party balances to currency precision in Trial Balance for Party (backport #58607)
2026-09-01 16:42:57 +05:30
Shllokkk
956a6ed407 fix: round party balances to currency precision in Trial Balance for Party
(cherry picked from commit b1c7657dfa)
2026-09-01 09:49:43 +00:00
Diptanil Saha
c745558168 fix(accounts): add permission checks on payment entry whitelisted methods (#58644) 2026-09-01 09:24:40 +00:00
mergify[bot]
fb74a030de fix: handle BOM price list currency update (backport #58638) (#58639)
fix: handle BOM price list currency update (#58638)

(cherry picked from commit db52e465d0)

Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-09-01 13:40:51 +05:30
Aarol D'Souza
de28957008 Merge pull request #58637 from frappe/mergify/bp/version-16-hotfix/pr-58586
fix(sms_settings): add patch to pre-fill roles into SMS Settings Role Table (backport #58586)
2026-09-01 13:05:22 +05:30
AarDG10
9ee421424f chore: resolve conflicts 2026-09-01 12:51:04 +05:30
AarDG10
4b89ddffde 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:30 +00:00
Mihir Kandoi
1b44df480f feat: valuation type for BOM secondary items (backport #58431) (#58632) 2026-09-01 11:45:52 +05:30
Mihir Kandoi
ff656b9ad2 feat(manufacturing): add multi-currency support to Blanket Orders (backport #58472) (#58630) 2026-09-01 11:39:26 +05:30
mergify[bot]
22755c4ad6 fix(manufacturing): handle duplicate root BOM items (backport #58614) (#58616)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-01 02:54:09 +00:00
mergify[bot]
8a8bafd6cb fix: explain missing fields when loading party details or taxes (backport #58627) (#58628)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-01 01:53:59 +02:00
mergify[bot]
9bb8d4ff93 fix: translate label in party validation (backport #58624) (#58626)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-01 00:54:12 +02:00
mergify[bot]
30f0cd9c95 fix(crm): add missing permission validation on get_contract_template (backport #58621) (#58623)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 19:40:50 +00:00
mergify[bot]
f8c29274a5 fix: validate POS Settings invoice and search fields on the server (backport #58611) (#58620)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-01 00:08:37 +05:30
mergify[bot]
f3379b4694 fix: do not map the same row twice in "Get Items From" (backport #58617) (#58619)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-31 19:40:59 +02:00
Lakshit Jain
2390d48c01 Merge pull request #58602 from frappe/mergify/bp/version-16-hotfix/pr-58483
fix(taxes): skip tax addition for invoice created from opening invoice tool (backport #58483)
2026-08-31 18:38:56 +05:30
mergify[bot]
765a7736ed fix(accounts): added permission checks on get_available_payment_schedules (backport #58588) (#58595)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 17:14:27 +05:30
mergify[bot]
d95eeb0f6a fix(pos): add permission checks on get_invoices (backport #58591) (#58597)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 17:14:16 +05:30
ljain112
32393caa39 chore: resolve conflicts 2026-08-31 16:54:30 +05:30
mergify[bot]
c7582e986d fix/payment-request-subscription-plans-population (backport #57494) (#58430)
* fix(payment-request): populate subscription plans (#57494)

* fix(payment-request): populate subscription plans

* test: add coverage for subscription plans in payment request

---------

Co-authored-by: Dharanidharan2813 <dharanidharans1328@gmail.com>
(cherry picked from commit 1b81db4754)

# Conflicts:
#	erpnext/accounts/doctype/payment_request/payment_request.py
#	erpnext/accounts/doctype/payment_request/test_payment_request.py

* fix(payment-request): resolve cherry-pick conflicts for version-16-hotfix backport

---------

Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: ervishnucs <ervishnucs369@gmail.com>
Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-08-31 16:28:20 +05:30
ljain112
bfbc13d147 fix(taxes): skip tax addition for invoice created from opening invoice tool
(cherry picked from commit e08a166281)

# Conflicts:
#	erpnext/accounts/services/taxes.py
2026-08-31 09:35:42 +00:00
Nishka Gosalia
40fe739c9e Merge pull request #58592 from frappe/mergify/bp/version-16-hotfix/pr-58325
fix(projects): check read permission on source in create_duplicate_project (backport #58325)
2026-08-31 14:17:07 +05:30
Shllokkk
bd35957582 fix(projects): check read permission on source in create_duplicate_project
(cherry picked from commit 8c8b282a2e)
2026-08-31 07:10:45 +00:00
Mihir Kandoi
e64fddfad5 fix(manufacturing): use packed row delivery date (#58570) 2026-08-30 11:06:06 +00:00
MochaMind
ac61e6ac0d chore: update POT file (#58567) 2026-08-30 13:01:08 +02:00
Henil Maru
cb4ec72370 fix: Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows (#58526) 2026-08-30 15:56:28 +05:30
mergify[bot]
06758f2792 fix(stock): load available serial no report (backport #58558) (#58562)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-29 14:56:27 +00:00
mergify[bot]
01a398f263 refactor(stock): remove dead warehouse_condition branch on get_stock_ledger_entries (backport #58552) (#58554)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-29 17:55:34 +05:30
Shllokkk
446b5375e4 Merge pull request #58544 from frappe/mergify/bp/version-16-hotfix/pr-58324
fix(crm): check write permission in edit_note (backport #58324)
2026-08-29 13:27:56 +05:30
Shllokkk
c6082617ec chore: resolving conflicts 2026-08-29 13:03:59 +05:30
Shllokkk
c2261f6129 fix(crm): check write permission in edit_note
(cherry picked from commit eb49f51d29)

# Conflicts:
#	erpnext/crm/utils.py
2026-08-28 15:05:24 +00:00
mergify[bot]
eb4c327a2b fix(stock): auto-select batch no before saving transaction records (backport #58536) (#58539)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-28 12:06:38 +00:00
mergify[bot]
629bc20e90 fix: persist redistributed additional costs during stock entry repost (backport #58433) (#58533)
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)

Co-authored-by: Afsal Syed <146159709+Afsalsyed@users.noreply.github.com>
2026-08-28 10:34:20 +00:00
Sudharsanan Ashok
cc73c3b879 Merge pull request #58495 from frappe/mergify/bp/version-16-hotfix/pr-58493
fix(selling): check quotation write permission before marking lost (backport #58493)
2026-08-28 13:11:58 +05:30
pandiyan
4e74831a08 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:01:15 +05:30
mergify[bot]
23290a60da fix(accounts): set pos profile on invoices respecting user permissions (backport #58508) (#58519)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-28 06:48:18 +00:00
mergify[bot]
ee3b22403d fix(manufacturing): classify MRP items without a BOM as Purchase (backport #58509) (#58513)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-27 17:37:09 +00:00
mergify[bot]
f0d2d40d35 fix: translate doctype in map msg (backport #58515) (#58517)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-27 19:25:51 +02:00
Sudharsanan Ashok
60252cacdd Merge pull request #58521 from frappe/revert-58489-mergify/bp/version-16-hotfix/pr-58486
Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)"
2026-08-27 22:46:17 +05:30
Sudharsanan Ashok
8287906a71 Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)" 2026-08-27 22:34:55 +05:30
mergify[bot]
d732d06e25 fix(projects): ignore cancelled invoices in timesheet portal (backport #58501) (#58504)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-27 13:04:09 +00:00
Shllokkk
4538689e5f Merge pull request #58502 from Shllokkk/work-order-transferred-qty-sum-alternate-items-v16
fix: sum item and its alternate transferred qty on work order
2026-08-27 18:27:56 +05:30
Shllokkk
7464f6c76e test: transferred qty sums item and its alternate 2026-08-27 18:07:57 +05:30
Shllokkk
1411835507 fix: sum item and its alternate transferred qty on work order required items 2026-08-27 18:07:57 +05:30
Mihir Kandoi
76d0cb382d fix(manufacturing): preserve job card qty in mr transfer (backport #58482) (#58499) 2026-08-27 12:19:33 +00:00
Sudharsanan Ashok
eedf991b43 Merge pull request #58489 from frappe/mergify/bp/version-16-hotfix/pr-58486
refactor(stock): use db.count for the empty ledger check (backport #58486)
2026-08-27 16:50:09 +05:30
Sudharsanan Ashok
38aaceafc6 refactor(stock): use db.count for the empty ledger check (#58486)
Align the existence check in `reset_bin_without_stock_ledger_entries()` with
the version-15-hotfix backport in #58434, per review feedback there.

(cherry picked from commit 2866be2816)
2026-08-27 11:07:21 +00:00
Abdeali Chharchhodawala
a43de7ce95 fix: improve message formatting and translation for validation issues (#58425)
(cherry picked from commit 6842ebb186)
2026-08-27 10:57:38 +00:00
Sudharsanan Ashok
f7f34ffd3b Merge pull request #58467 from frappe/mergify/bp/version-16-hotfix/pr-58466
fix(manufacturing): account for pending job card qty (backport #58466)
2026-08-27 16:13:54 +05:30
Sudharsanan Ashok
f19450858c Merge pull request #58475 from frappe/mergify/bp/version-16-hotfix/pr-58469
fix: clarify duplicate internal party messages (backport #58469)
2026-08-27 16:13:28 +05:30
Sudharsanan Ashok
8dc3bc3485 Merge pull request #58429 from frappe/mergify/bp/version-16-hotfix/pr-58402
feat(analytics): filter sales and purchase analytics by entity  (backport #58402)
2026-08-27 16:12:20 +05:30
pandiyan
7b9cdb4926 fix(manufacturing): account for pending job card qty (#58466)
(cherry picked from commit 0d90608bc1)

# Conflicts:
#	erpnext/manufacturing/doctype/job_card/job_card.py
#	erpnext/manufacturing/doctype/job_card/test_job_card.py
2026-08-27 14:03:00 +05:30
pandiyan
f0e603aec5 fix: clarify duplicate internal party messages (#58469)
(cherry picked from commit 4d4cf034b5)

# Conflicts:
#	erpnext/selling/doctype/customer/customer.py
2026-08-27 13:57:12 +05:30
Shllokkk
bfb27039ed Merge pull request #58470 from Shllokkk/material-request-validate-with-so-v16
fix: validate items against source Sales Order in Material Request
2026-08-27 13:06:38 +05:30
Shllokkk
07f8250e40 test: block item change on Sales Order-sourced Material Request row 2026-08-27 12:48:45 +05:30
Shllokkk
65466464df fix: validate items against source Sales Order in Material Request 2026-08-27 12:48:45 +05:30
Diptanil Saha
60599ec692 ci: crowdin actions to upload main.pot file (#58461) 2026-08-26 18:31:04 +00:00
Aarol D'Souza
6093316ba3 Merge pull request #58427 from frappe/mergify/bp/version-16-hotfix/pr-58351
fix(company): throw if linked to demo_company field (backport #58351)
2026-08-26 23:14:32 +05:30
mergify[bot]
cabecd2527 ci: ignore python tests on update of **.po (backport #58457) (#58459)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-26 20:43:42 +05:30
MochaMind
c924de3c55 fix: sync translations from crowdin (version-16-hotfix) (#58455)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-26 18:31:46 +05:30
Sudharsanan Ashok
655ed81575 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/test_purchase_analytics.py
#	erpnext/selling/report/sales_analytics/test_sales_analytics.py
2026-08-26 07:33:42 +00:00
AarDG10
9f3dd7eabd 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:12 +00:00
Sudharsanan Ashok
2cd865fffc Merge pull request #58405 from aerele/fix/pick-list-transfer-qty-lost-v16
fix(stock): keep pick list links when refetching stock entry items (backport #58374)
2026-08-26 12:45:46 +05:30
Sudharsanan Ashok
e32a8f008e Merge pull request #58422 from frappe/mergify/bp/version-16-hotfix/pr-58168
fix: correct is_frozen description on customer and supplier (backport #58168)
2026-08-26 12:39:35 +05:30
Pandiyan P
9e00888d67 fix: correct is_frozen description on customer and supplier (#58168)
(cherry picked from commit cc60c80e68)

# Conflicts:
#	erpnext/buying/doctype/supplier/supplier.json
#	erpnext/selling/doctype/customer/customer.json
2026-08-26 12:08:14 +05:30
Sudharsanan Ashok
5b92dff1df Merge pull request #58419 from frappe/mergify/bp/version-16-hotfix/pr-58394
fix(stock): validate serial inventory dimensions (backport #58394)
2026-08-26 11:51:09 +05:30
Pandiyan P
a1aabaff18 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/stock_ledger_entry/stock_ledger_entry.py
2026-08-26 11:37:57 +05:30
Khushi Rawat
1a3e02ee5b Merge pull request #58414 from frappe/mergify/bp/version-16-hotfix/pr-58412
fix(accounts): prevent child table doctypes as accounting dimensions (backport #58412)
2026-08-26 11:35:41 +05:30
khushi8112
abf311e8ca fix(accounts): guard get_gl_dict for v16 and drop unused base_gl_composer 2026-08-26 11:05:19 +05:30
khushi8112
fe98974cb6 test(accounts): cover single doctype and non-scalar dimension safeguards
(cherry picked from commit 55dd11f977)
2026-08-25 19:18:25 +00:00
khushi8112
6dbaa5048e fix(accounts): prevent child table doctypes as accounting dimensions
(cherry picked from commit 7b32d07d1c)

# Conflicts:
#	erpnext/accounts/services/base_gl_composer.py
2026-08-25 19:18:24 +00:00
Frappe PR Bot
b24c9eba55 chore(release): Bumped to Version 16.33.0
# [16.33.0](https://github.com/frappe/erpnext/compare/v16.32.3...v16.33.0) (2026-08-25)

### Bug Fixes

* **accounts:** allocate drop-ship cost by invoice quantity (backport [#58226](https://github.com/frappe/erpnext/issues/58226)) ([#58263](https://github.com/frappe/erpnext/issues/58263)) ([a62a949](a62a949b2a))
* **accounts:** disallow reversing a reverse journal entry ([8a26834](8a26834704))
* **accounts:** match returns to source invoice items (backport [#58250](https://github.com/frappe/erpnext/issues/58250)) ([#58264](https://github.com/frappe/erpnext/issues/58264)) ([9b0db26](9b0db26c25))
* **accounts:** supplier group filter not applied on accounts payable report ([d23b919](d23b9197d5))
* aggregate child warehouses in Stock Qty vs Serial No Count report (backport [#58134](https://github.com/frappe/erpnext/issues/58134)) ([#58366](https://github.com/frappe/erpnext/issues/58366)) ([abe4c0a](abe4c0a6b7))
* apply Sales Person user permissions in Accounts Receivable ([956be58](956be58c9c))
* **assets:** split FIFO/LIFO rate across grouped stock item rows ([98bef1c](98bef1cac8))
* **bank_statement_import:** add missing permission check on `get_import_status` (backport [#58217](https://github.com/frappe/erpnext/issues/58217)) ([#58219](https://github.com/frappe/erpnext/issues/58219)) ([2b685ed](2b685ed982))
* **bank_statement_import:** add missing permission check on multiple whitelisted methods (backport [#58221](https://github.com/frappe/erpnext/issues/58221)) ([#58224](https://github.com/frappe/erpnext/issues/58224)) ([81461ea](81461ea56c))
* block disabled/frozen party on Opportunity and Request for Quotation (backport [#57983](https://github.com/frappe/erpnext/issues/57983)) ([#58035](https://github.com/frappe/erpnext/issues/58035)) ([9f99ba6](9f99ba6ddc))
* **buying:** allow purchase returns against a closed purchase order ([#58139](https://github.com/frappe/erpnext/issues/58139)) ([61b5499](61b549960e))
* conflicts ([cbe7931](cbe7931b3b))
* correct Item Group doctype name in item tax template dashboard  (backport [#58192](https://github.com/frappe/erpnext/issues/58192)) ([#58213](https://github.com/frappe/erpnext/issues/58213)) ([56ac872](56ac87292f))
* correct to and from date filters in timesheet billing summary re… (backport [#58317](https://github.com/frappe/erpnext/issues/58317)) ([#58318](https://github.com/frappe/erpnext/issues/58318)) ([2077e84](2077e844d4))
* **crm_settings:** create custom fields for Frappe CRM on enabling synchronization ([795cf85](795cf8544c))
* describe stale exchange rate settings ([1ec2a2c](1ec2a2cd5d))
* don't set work order status to In Process only due to skip material transfer ([#58245](https://github.com/frappe/erpnext/issues/58245)) ([dd4d108](dd4d10862d))
* drop removed Restaurant doctype from sales tax template dashboard  (backport [#58191](https://github.com/frappe/erpnext/issues/58191)) ([#58211](https://github.com/frappe/erpnext/issues/58211)) ([c3cf7f2](c3cf7f2e91))
* **email_digest:** added permission check for `get_msg_html` (backport [#58197](https://github.com/frappe/erpnext/issues/58197)) ([#58200](https://github.com/frappe/erpnext/issues/58200)) ([32ebd69](32ebd69abc))
* escape interpolated values in portal, print and desk templates (backport [#58273](https://github.com/frappe/erpnext/issues/58273)) ([#58278](https://github.com/frappe/erpnext/issues/58278)) ([6b7b479](6b7b4796b4))
* escape interpolated values in text positions across portal and desk templates (backport [#58286](https://github.com/frappe/erpnext/issues/58286)) ([#58288](https://github.com/frappe/erpnext/issues/58288)) ([d357310](d3573105c4))
* escape on status image for workstations in production status (backport [#58279](https://github.com/frappe/erpnext/issues/58279)) ([#58281](https://github.com/frappe/erpnext/issues/58281)) ([9e2173e](9e2173e4c2))
* filter available batch report by company (backport [#57995](https://github.com/frappe/erpnext/issues/57995)) ([#58077](https://github.com/frappe/erpnext/issues/58077)) ([5933476](5933476778))
* get items from sales order in sales invoice ([#58163](https://github.com/frappe/erpnext/issues/58163)) ([0255314](0255314ea7))
* hide rfq status in supplier portal ([#58368](https://github.com/frappe/erpnext/issues/58368)) ([8a2109e](8a2109e62a))
* hide supplier name in rfq portal (backport [#58373](https://github.com/frappe/erpnext/issues/58373)) ([#58376](https://github.com/frappe/erpnext/issues/58376)) ([2329ef6](2329ef6424))
* ignore historical negative batch stock in outward validation (backport [#58148](https://github.com/frappe/erpnext/issues/58148)) ([#58151](https://github.com/frappe/erpnext/issues/58151)) ([c18881b](c18881b37d))
* Ignore permission while deleting user permission (backport [#57296](https://github.com/frappe/erpnext/issues/57296)) ([#57760](https://github.com/frappe/erpnext/issues/57760)) ([98d69b2](98d69b2ae3))
* include time logs ending at midnight in timesheet billing summary (backport [#58355](https://github.com/frappe/erpnext/issues/58355)) ([#58357](https://github.com/frappe/erpnext/issues/58357)) ([0bf34c0](0bf34c054a))
* **italy:** handle none price_list_rate in e-invoice xml generation (backport [#58242](https://github.com/frappe/erpnext/issues/58242)) ([#58370](https://github.com/frappe/erpnext/issues/58370)) ([feb51a4](feb51a475a))
* keep Currency and Price List section open for foreign currency (backport [#58074](https://github.com/frappe/erpnext/issues/58074)) ([#58113](https://github.com/frappe/erpnext/issues/58113)) ([a3a6efb](a3a6efbcea))
* **manufacturing:** derive material transfers from actual coverage ([#58115](https://github.com/frappe/erpnext/issues/58115)) ([2a9e430](2a9e4304a2))
* **manufacturing:** fall back to item group defaults for work order w… ([#58237](https://github.com/frappe/erpnext/issues/58237)) ([861fb26](861fb26b2c))
* **manufacturing:** keep MRP chart dates distinct ([1b37fd2](1b37fd2edc))
* **Material Requirements Planning Report:** detailed-view chart timescale ([de06cb7](de06cb7f45)), closes [#52632](https://github.com/frappe/erpnext/issues/52632)
* new docs should refetch incoming rates (backport [#58097](https://github.com/frappe/erpnext/issues/58097)) ([#58295](https://github.com/frappe/erpnext/issues/58295)) ([edb9b33](edb9b33264))
* patch to delete the `crm_deal` custom fields ([d66dc14](d66dc143e3))
* prevent duplicate supplier quotations from portal ([39e15c7](39e15c7b2d))
* prevent TimestampMismatchError resolving Dunning with multiple overdue installments ([5764d1d](5764d1d173))
* **projects:** respect permissions in timesheet billing summary (backport [#58320](https://github.com/frappe/erpnext/issues/58320)) ([#58322](https://github.com/frappe/erpnext/issues/58322)) ([647c933](647c93321b))
* Qty and UOM not fetched when adding Item in Material Request ([#58118](https://github.com/frappe/erpnext/issues/58118)) ([aecc551](aecc551b5e))
* renaming the overdue billing checkbox (backport [#58165](https://github.com/frappe/erpnext/issues/58165)) ([#58166](https://github.com/frappe/erpnext/issues/58166)) ([1ea1d5d](1ea1d5d6a5))
* render missing terms before printing ([#58358](https://github.com/frappe/erpnext/issues/58358)) ([f56cdac](f56cdac45f))
* resolve backport conflicts ([3fd3f94](3fd3f9485b))
* respect zero currency precision ([#58395](https://github.com/frappe/erpnext/issues/58395)) ([6b61146](6b61146d2f))
* rewriting the description of the overdue billing checkbox (backport [#58172](https://github.com/frappe/erpnext/issues/58172)) ([#58174](https://github.com/frappe/erpnext/issues/58174)) ([4bb7799](4bb7799919))
* run set_missing_values before creating Purchase Order from MRP report ([72114fa](72114fa738))
* **selling:** read overdue amount from payment ledger, not gl tags  (backport [#57786](https://github.com/frappe/erpnext/issues/57786)) ([#58026](https://github.com/frappe/erpnext/issues/58026)) ([29f6a5b](29f6a5b9cf))
* send auto reorder email to all managers in single company setup ([ac1c692](ac1c6921da))
* skip covered rows when ordering from the mrp report ([#58249](https://github.com/frappe/erpnext/issues/58249)) ([#58312](https://github.com/frappe/erpnext/issues/58312)) ([dfc2301](dfc2301f08))
* **stock:** confirm before changing item qty from the batch selector (backport [#58123](https://github.com/frappe/erpnext/issues/58123)) ([#58125](https://github.com/frappe/erpnext/issues/58125)) ([4f3ffd3](4f3ffd3820))
* **stock:** fetch item stock UOM in stock reconciliation (backport [#58284](https://github.com/frappe/erpnext/issues/58284)) ([#58291](https://github.com/frappe/erpnext/issues/58291)) ([1f21c0b](1f21c0b0af))
* **stock:** honour pick serial / batch based on in the batch selector (backport [#58176](https://github.com/frappe/erpnext/issues/58176)) ([#58182](https://github.com/frappe/erpnext/issues/58182)) ([d3a4d47](d3a4d476eb))
* **stock:** preserve item UOM conversion factor (backport [#58299](https://github.com/frappe/erpnext/issues/58299)) ([#58304](https://github.com/frappe/erpnext/issues/58304)) ([53b61e5](53b61e5ac6))
* **stock:** reset bin when no stock ledger entries remain ([#58362](https://github.com/frappe/erpnext/issues/58362)) ([37a1fd1](37a1fd11e9))
* stop doubling totals in timesheet billing summary (backport [#58311](https://github.com/frappe/erpnext/issues/58311)) ([#58315](https://github.com/frappe/erpnext/issues/58315)) ([c3313f5](c3313f5b34))
* use user data fields hook (backport [#58274](https://github.com/frappe/erpnext/issues/58274)) ([#58283](https://github.com/frappe/erpnext/issues/58283)) ([94b6622](94b6622d9d))
* validation for task end date check ([7b0df4b](7b0df4b28b))
* work order finish dialog with process loss qty from job card (backport [#58256](https://github.com/frappe/erpnext/issues/58256)) ([#58262](https://github.com/frappe/erpnext/issues/58262)) ([27a04d8](27a04d8e08))

### Features

* **accounts:** opt-in 'Consider Accounting Dimension' filter on General Ledger Report (backport [#58156](https://github.com/frappe/erpnext/issues/58156)) ([#58158](https://github.com/frappe/erpnext/issues/58158)) ([f8c3270](f8c3270049))
* add status filter to Supplier Quotation Comparison report ([2b84ed7](2b84ed78e8))
* Belgian Charts of Accounts (commercial + non-profit, FR + NL) (backport [#54679](https://github.com/frappe/erpnext/issues/54679)) ([#58186](https://github.com/frappe/erpnext/issues/58186)) ([d805f4d](d805f4d3fc))
* taxable-base resolver hook for custom charge types ([#56175](https://github.com/frappe/erpnext/issues/56175)) ([ea238a9](ea238a90ac))
* validate purchase receipt exchange rate parity on purchase invoice (backport [#58177](https://github.com/frappe/erpnext/issues/58177)) ([#58190](https://github.com/frappe/erpnext/issues/58190)) ([900064e](900064e395))
2026-08-25 17:04:28 +00:00
Diptanil Saha
1a745cd433 Merge pull request #58397 from frappe/version-16-hotfix
chore: release v16
2026-08-25 22:21:37 +05:30
Shllokkk
7238ecb306 Merge pull request #58407 from frappe/mergify/bp/version-16-hotfix/pr-58400
fix: send auto reorder email to all managers in single company setup (backport #58400)
2026-08-25 18:30:12 +05:30
Shllokkk
97b2e07d5e test: auto reorder email reaches managers without company user permission
(cherry picked from commit a01cc92184)
2026-08-25 12:08:44 +00:00
Shllokkk
ac1c6921da fix: send auto reorder email to all managers in single company setup
(cherry picked from commit ae119b1c29)
2026-08-25 12:08:43 +00:00
Sudharsanan11
01e0844d40 fix(stock): keep pick list links when refetching stock entry items 2026-08-25 17:05:23 +05:30
Sudharsanan Ashok
c9550023f8 Merge pull request #58399 from frappe/mergify/bp/version-16-hotfix/pr-58395
fix: respect zero currency precision (backport #58395)
2026-08-25 16:45:34 +05:30
Pandiyan P
6b61146d2f fix: respect zero currency precision (#58395)
(cherry picked from commit ce23fcc055)
2026-08-25 10:06:39 +00:00
Sudharsanan Ashok
9209912374 Merge pull request #58353 from frappe/mergify/bp/version-16-hotfix/pr-58179
fix(accounts): supplier group filter not applied on accounts payable … (backport #58179)
2026-08-25 13:04:52 +05:30
Sudharsanan Ashok
f1181fa1e3 Merge pull request #58391 from frappe/mergify/bp/version-16-hotfix/pr-58362
fix(stock): reset bin when no stock ledger entries remain (backport #58362)
2026-08-25 12:46:31 +05:30
Sudharsanan Ashok
37a1fd11e9 fix(stock): reset bin when no stock ledger entries remain (#58362)
* fix(stock): reset bin when no stock ledger entries remain

update_bin() only writes bins reachable through prev_sle_dict, and that
dict is empty once the last live sle for an item and warehouse is
cancelled or deleted. actual_qty is still recomputed, but stock_value
and valuation_rate stay stale and a repost cannot heal them, so bin
totals drift permanently from the stock balance.

zero those bins after the normal update, guarded by a re-check that no
live sle exists. also drop the prev_sle_dict seeding added earlier in
initialize_previous_data, which never took effect because
initialize_reposting() discards the dict before update_bin() reads it.

* test(stock): cover bin reset when ledger is empty

three cases that all leave an item and warehouse with no live sle:
cancelling the only voucher, deleting it with delete_linked_ledger_entries
on, and reposting over an already emptied ledger. each asserts actual_qty,
valuation_rate and stock_value are all zero.

(cherry picked from commit 6fbcfade6c)
2026-08-25 06:10:18 +00:00
Sudharsanan Ashok
f71bc4d3ef Merge pull request #58384 from aerele/backport-58377-version-16-hotfix
fix: prevent duplicate supplier quotations from portal (backport #58377)
2026-08-25 11:12:10 +05:30
pandiyan
efe5571ca7 test: verify duplicate supplier quotations are rejected 2026-08-24 23:57:59 +05:30
pandiyan
39e15c7b2d fix: prevent duplicate supplier quotations from portal 2026-08-24 23:57:59 +05:30
mergify[bot]
2329ef6424 fix: hide supplier name in rfq portal (backport #58373) (#58376)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-24 12:11:31 +00:00
mergify[bot]
83cc51a5d2 Fix/return qty validation different uom (backport #58298) (#58364)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
2026-08-24 17:39:41 +05:30
mergify[bot]
feb51a475a fix(italy): handle none price_list_rate in e-invoice xml generation (backport #58242) (#58370)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-08-24 17:39:16 +05:30
mergify[bot]
5933476778 fix: filter available batch report by company (backport #57995) (#58077)
Co-authored-by: Krishna Shirsath <shirsathkrishna19@gmail.com>
2026-08-24 11:19:45 +00:00
Sudharsanan Ashok
fb9edb3bf8 Merge pull request #58361 from frappe/mergify/bp/version-16-hotfix/pr-58358
fix: render missing terms before printing (backport #58358)
2026-08-24 16:37:06 +05:30
Sudharsanan Ashok
9d9d9f09fd Merge pull request #58372 from frappe/mergify/bp/version-16-hotfix/pr-58368
fix: hide rfq status in supplier portal (backport #58368)
2026-08-24 16:36:08 +05:30
Pandiyan P
8a2109e62a fix: hide rfq status in supplier portal (#58368)
(cherry picked from commit 75d6183bb6)
2026-08-24 11:00:24 +00:00
mergify[bot]
abe4c0a6b7 fix: aggregate child warehouses in Stock Qty vs Serial No Count report (backport #58134) (#58366)
Co-authored-by: Mohd Haris <haris@frappe.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-24 10:24:40 +00:00
Pandiyan P
f56cdac45f fix: render missing terms before printing (#58358)
(cherry picked from commit 59d80b29c4)
2026-08-24 09:24:08 +00:00
mergify[bot]
0bf34c054a fix: include time logs ending at midnight in timesheet billing summary (backport #58355) (#58357)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-24 14:46:46 +05:30
ervishnucs
d23b9197d5 fix(accounts): supplier group filter not applied on accounts payable report
(cherry picked from commit 513f19924d)
2026-08-24 07:09:12 +00:00
mergify[bot]
8dbe4bd7fa ci: bump po-review-action (backport #58346) (#58348)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-23 18:22:37 +00:00
MochaMind
d321eb4a8a chore: update POT file (#58344) 2026-08-23 13:33:34 +02:00
Mihir Kandoi
a1520d06ee chore: rename type field to secondary_item_type (backport #55469) (#58342) 2026-08-23 14:07:59 +05:30
mergify[bot]
647c93321b fix(projects): respect permissions in timesheet billing summary (backport #58320) (#58322)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-20 15:48:55 +05:30
mergify[bot]
2077e844d4 fix: correct to and from date filters in timesheet billing summary re… (backport #58317) (#58318)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-20 11:13:42 +05:30
mergify[bot]
c3313f5b34 fix: stop doubling totals in timesheet billing summary (backport #58311) (#58315)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-20 03:03:06 +00:00
Pandiyan P
dfc2301f08 fix: skip covered rows when ordering from the mrp report (#58249) (#58312) 2026-08-20 07:43:16 +05:30
mergify[bot]
53b61e5ac6 fix(stock): preserve item UOM conversion factor (backport #58299) (#58304)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 12:01:42 +00:00
mergify[bot]
a3a6efbcea fix: keep Currency and Price List section open for foreign currency (backport #58074) (#58113)
* 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/stock/doctype/delivery_note/delivery_note.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-19 16:59:07 +05:30
mergify[bot]
9f99ba6ddc fix: block disabled/frozen party on Opportunity and Request for Quotation (backport #57983) (#58035)
* fix: block disabled/frozen customers on Opportunity

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

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

(cherry picked from commit 90937ce6d9)

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

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

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

(cherry picked from commit 4bf65ffc1d)

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

* fix: scope Opportunity party validation to Customer only

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

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

(cherry picked from commit 8c0a945417)

* refactor: move RFQ supplier disabled filter to link_filters

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

(cherry picked from commit 6b35c51ff1)

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

The automated backport left unresolved merge conflict markers committed
in request_for_quotation.py and test_request_for_quotation.py.

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

---------

Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-08-19 16:57:42 +05:30
mergify[bot]
1f21c0b0af fix(stock): fetch item stock UOM in stock reconciliation (backport #58284) (#58291)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 10:09:44 +00:00
Khushi Rawat
f813774f63 Merge pull request #57771 from frappe/mergify/bp/version-16-hotfix/pr-57539
fix: prevent TimestampMismatchError resolving Dunning with multiple overdue installments (backport #57539)
2026-08-19 15:05:36 +05:30
mergify[bot]
98d69b2ae3 fix: Ignore permission while deleting user permission (backport #57296) (#57760)
Co-authored-by: Deepesh Garg <deepeshgarg6@gmail.com>
2026-08-19 14:49:33 +05:30
mergify[bot]
29f6a5b9cf fix(selling): read overdue amount from payment ledger, not gl tags (backport #57786) (#58026)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-19 14:41:47 +05:30
mergify[bot]
edb9b33264 fix: new docs should refetch incoming rates (backport #58097) (#58295)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 07:19:06 +00:00
mergify[bot]
94b6622d9d fix: use user data fields hook (backport #58274) (#58283)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 10:25:55 +05:30
mergify[bot]
d3573105c4 fix: escape interpolated values in text positions across portal and desk templates (backport #58286) (#58288) 2026-08-19 02:43:18 +05:30
mergify[bot]
9e2173e4c2 fix: escape on status image for workstations in production status (backport #58279) (#58281)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-19 00:17:17 +05:30
mergify[bot]
6b7b4796b4 fix: escape interpolated values in portal, print and desk templates (backport #58273) (#58278)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-19 00:16:52 +05:30
rohitwaghchaure
27a04d8e08 fix: work order finish dialog with process loss qty from job card (backport #58256) (#58262)
* fix(manufacturing): cap job card completed qty by previous operation and show process loss on finish dialog

* fix: avoid double booking process loss on partial manufacture entries
2026-08-18 09:47:32 +00:00
mergify[bot]
9b0db26c25 fix(accounts): match returns to source invoice items (backport #58250) (#58264)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-18 09:08:31 +00:00
mergify[bot]
a62a949b2a fix(accounts): allocate drop-ship cost by invoice quantity (backport #58226) (#58263)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-18 08:41:58 +00:00
rohitwaghchaure
dd4d10862d fix: don't set work order status to In Process only due to skip material transfer (#58245) 2026-08-17 18:28:46 +05:30
Pandiyan P
861fb26b2c fix(manufacturing): fall back to item group defaults for work order w… (#58237) 2026-08-17 15:26:15 +05:30
mergify[bot]
56ac87292f fix: correct Item Group doctype name in item tax template dashboard (backport #58192) (#58213)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-17 10:52:42 +05:30
mergify[bot]
81461ea56c fix(bank_statement_import): add missing permission check on multiple whitelisted methods (backport #58221) (#58224)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 18:03:24 +00:00
mergify[bot]
2b685ed982 fix(bank_statement_import): add missing permission check on get_import_status (backport #58217) (#58219)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 15:04:21 +00:00
mergify[bot]
6a0b9e1c70 fix(crm)!: remove unused get_last_interaction endpoint (backport #58214) (#58216)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 14:16:29 +00:00
MochaMind
28fe021d79 chore: update POT file (#58207) 2026-08-16 18:03:26 +05:30
mergify[bot]
c3cf7f2e91 fix: drop removed Restaurant doctype from sales tax template dashboard (backport #58191) (#58211) 2026-08-16 17:28:32 +05:30
Shllokkk
832a0bd1ed Merge pull request #58208 from Shllokkk/supplier-quotation-comparison-status-filter-v16
feat: add status filter to Supplier Quotation Comparison report
2026-08-16 15:52:17 +05:30
Shllokkk
2b84ed78e8 feat: add status filter to Supplier Quotation Comparison report 2026-08-16 15:34:10 +05:30
mergify[bot]
32ebd69abc fix(email_digest): added permission check for get_msg_html (backport #58197) (#58200)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-15 16:40:09 +00:00
mergify[bot]
900064e395 feat: validate purchase receipt exchange rate parity on purchase invoice (backport #58177) (#58190)
feat: validate purchase receipt exchange rate parity on purchase invoice (#58177)

(cherry picked from commit 70a8a2d0c5)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-15 15:20:17 +05:30
mergify[bot]
d805f4d3fc feat: Belgian Charts of Accounts (commercial + non-profit, FR + NL) (backport #54679) (#58186)
Co-authored-by: Antoine Maas <antoine.maas@okte.io>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-14 16:57:25 +00:00
mergify[bot]
d3a4d476eb fix(stock): honour pick serial / batch based on in the batch selector (backport #58176) (#58182)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-14 13:58:07 +00:00
mergify[bot]
4bb7799919 fix: rewriting the description of the overdue billing checkbox (backport #58172) (#58174)
fix: rewriting the description of the overdue billing checkbox (#58172)

fix: rewritting the description of the overdue billing checkbox
(cherry picked from commit cbf8f61101)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-08-14 17:43:14 +05:30
Nishka Gosalia
c15d5d1a0b Merge pull request #58171 from frappe/mergify/bp/version-16-hotfix/pr-58167
fix: validation for task end date check (backport #58167)
2026-08-14 16:36:33 +05:30
nishkagosalia
7b0df4b28b fix: validation for task end date check
(cherry picked from commit 7c6da80f98)
2026-08-14 10:53:06 +00:00
mergify[bot]
1ea1d5d6a5 fix: renaming the overdue billing checkbox (backport #58165) (#58166)
fix: renaming the overdue billing checkbox (#58165)

(cherry picked from commit 917badb82b)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-08-14 16:05:22 +05:30
Smit Vora
aa03ddc8e6 Merge pull request #58161 from frappe/mergify/bp/version-16-hotfix/pr-56175
feat: taxable-base resolver hook for custom charge types (backport #56175)
2026-08-14 15:16:39 +05:30
Mihir Kandoi
0255314ea7 fix: get items from sales order in sales invoice (#58163) 2026-08-14 09:20:10 +00:00
mergify[bot]
f8c3270049 feat(accounts): opt-in 'Consider Accounting Dimension' filter on General Ledger Report (backport #58156) (#58158) 2026-08-14 08:25:09 +00:00
Smit Vora
ea238a90ac feat: taxable-base resolver hook for custom charge types (#56175)
(cherry picked from commit 986cea2331)
2026-08-14 08:19:48 +00:00
Khushi Rawat
f03b7ba40b Merge pull request #58155 from frappe/mergify/bp/version-16-hotfix/pr-58092
fix(accounts): disallow reversing a reverse journal entry (backport #58092)
2026-08-14 13:20:43 +05:30
pandiyan
8a26834704 fix(accounts): disallow reversing a reverse journal entry
check read permission on the source entry before the guards run, so the
reversal relationship is not disclosed to a user who cannot read it.

(cherry picked from commit 9dd37d5f32)
2026-08-14 11:37:06 +05:30
pandiyan
271d22fff1 test(accounts): cover reversal of a reverse journal entry
also assert that a user without read access on the entry gets a permission error instead of the reversal relationship.

(cherry picked from commit 80422d2108)
2026-08-14 06:01:39 +00:00
mergify[bot]
c18881b37d fix: ignore historical negative batch stock in outward validation (backport #58148) (#58151)
fix: ignore historical negative batch stock in outward validation (#58148)

(cherry picked from commit 9239d1c2a3)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-14 11:18:50 +05:30
Pandiyan P
61b549960e fix(buying): allow purchase returns against a closed purchase order (#58139) 2026-08-14 09:00:55 +05:30
Diptanil Saha
ea52ab36d1 Merge pull request #58143 from frappe/mergify/bp/version-16-hotfix/pr-58141
fix(crm_settings): create `crm_deal` fields on enabling frappe crm data synchronization (backport #58141)
2026-08-13 23:20:41 +05:30
diptanilsaha
d66dc143e3 fix: patch to delete the crm_deal custom fields
(cherry picked from commit 9613d72d81)
2026-08-13 17:40:12 +00:00
diptanilsaha
795cf8544c fix(crm_settings): create custom fields for Frappe CRM on enabling synchronization
(cherry picked from commit be2dea0ba2)
2026-08-13 17:40:12 +00:00
mergify[bot]
4f3ffd3820 fix(stock): confirm before changing item qty from the batch selector (backport #58123) (#58125) 2026-08-13 19:37:31 +05:30
mergify[bot]
f9fd3bc526 chore(deps): update eslint in banking app (backport #58129) (#58137)
chore(deps): update eslint in banking app (#58129)

(cherry picked from commit 0bbaf4da3e)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-08-13 13:30:40 +00:00
mergify[bot]
5afaffe7b4 chore: update deps in banking app (backport #57971) (#58130)
chore: update deps in banking app (#57971)

(cherry picked from commit 5b2952aa23)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-08-13 12:32:12 +00:00
Khushi Rawat
569ad8e89b Merge pull request #58122 from frappe/mergify/bp/version-16-hotfix/pr-58120
fix: describe stale exchange rate settings (backport #58120)
2026-08-13 15:33:29 +05:30
Khushi Rawat
cbe7931b3b fix: conflicts 2026-08-13 15:22:38 +05:30
Lakshit Jain
03124b9560 Merge pull request #58027 from frappe/mergify/bp/version-16-hotfix/pr-57965
fix: run set_missing_values before creating Purchase Order from MRP report (backport #57965)
2026-08-13 15:21:11 +05:30
khushi8112
1ec2a2cd5d fix: describe stale exchange rate settings
(cherry picked from commit 84cdd0daf0)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json
2026-08-13 09:41:37 +00:00
Mihir Kandoi
2a9e4304a2 fix(manufacturing): derive material transfers from actual coverage (#58115) 2026-08-13 15:06:14 +05:30
Henil Maru
aecc551b5e fix: Qty and UOM not fetched when adding Item in Material Request (#58118) 2026-08-13 14:46:05 +05:30
ljain112
72114fa738 fix: run set_missing_values before creating Purchase Order from MRP report
(cherry picked from commit 94d363851f)
2026-08-13 14:12:53 +05:30
Lakshit Jain
e51df54439 Merge pull request #57120 from frappe/mergify/bp/version-16-hotfix/pr-56912
refactor: move functionality in postprocess for mapped doc (backport #56912)
2026-08-13 14:06:27 +05:30
Khushi Rawat
cf6c058f53 Merge pull request #58104 from frappe/mergify/bp/version-16-hotfix/pr-58078
fix: apply Sales Person user permissions in Accounts Receivable (backport #58078)
2026-08-13 12:28:49 +05:30
khushi8112
3fd3f9485b fix: resolve backport conflicts 2026-08-13 11:52:26 +05:30
Khushi Rawat
007748346c Merge pull request #58089 from frappe/mergify/bp/version-16-hotfix/pr-57820
fix(assets): split fifo/lifo rate across grouped stock item rows (backport #57820)
2026-08-13 11:49:15 +05:30
khushi8112
956be58c9c fix: apply Sales Person user permissions in Accounts Receivable
The report only narrowed by sales person when the filter was set, so a user
restricted to a Sales Person saw every row once the filter was cleared.

Resolve the permitted Sales Persons from user permissions and apply them on
top of the filter. Each Sales Team parent type is matched against its own
applicable_for scope, so a permission scoped to one doctype cannot authorise
rows through the other. Descendants are already expanded by
get_user_permissions, so Hide Descendants is respected. Gated to Receivable,
since the class is shared with Accounts Payable.

(cherry picked from commit 8b09ba429e)

# Conflicts:
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.py
2026-08-13 06:16:07 +00:00
mergify[bot]
0fe5436a13 Fix/item description in the item price list (backport #58084) (#58102)
Co-authored-by: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com>
2026-08-13 04:23:10 +00:00
Mihir Kandoi
0b01a93e2e Merge pull request #58090 from frappe/mergify/bp/version-16-hotfix/pr-58073
fix(manufacturing): show full date range in MRP chart (backport #58073)
2026-08-12 15:25:53 +05:30
Mihir Kandoi
58a489f73f chore: resolve conflict 2026-08-12 15:14:42 +05:30
Mihir Kandoi
1b37fd2edc fix(manufacturing): keep MRP chart dates distinct
(cherry picked from commit 5ad085887d)
2026-08-12 09:24:57 +00:00
Mihir Kandoi
5b68db0156 test(manufacturing): cover MRP chart date range
(cherry picked from commit 592924cc0d)

# Conflicts:
#	erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py
2026-08-12 09:24:56 +00:00
soulxone
de06cb7f45 fix(Material Requirements Planning Report): detailed-view chart timescale
The detailed-view chart collapsed every row into a single "today" column
and was additionally capped at 10 points, so the chart never matched the
report's date filters or the table data.

Two causes in get_detailed_view_chart_data:

1. `row.deliver_date` was a typo for `row.delivery_date` (the name used
   everywhere else in this report). On a frappe._dict the missing
   attribute resolves to None, so `getdate(None)` returned today and the
   past-date filter silently compared every row against today instead of
   its own delivery date.

2. A hard `if i == 10: break` truncated the chart to 10 date buckets.

Use the correct field name and drop the cap. The null check now runs
before the date comparison, since `getdate(None)` returning today meant
the original ordering could never filter a null delivery_date out.

Fixes #52632

(cherry picked from commit 3c17a604be)
2026-08-12 09:24:56 +00:00
ervishnucs
c014e01144 test(assets): cover grouped stock item rows splitting FIFO rate
(cherry picked from commit 2cbc5b89d6)
2026-08-12 09:05:15 +00:00
ervishnucs
98bef1cac8 fix(assets): split FIFO/LIFO rate across grouped stock item rows
(cherry picked from commit a05ec49062)
2026-08-12 09:05:15 +00:00
ljain112
b412266e18 chore: resolve conflicts 2026-08-05 17:01:39 +05:30
ljain112
3141387949 chore: resolve conflicts 2026-08-05 12:33:01 +05:30
ljain112
6bee70596b refactor: move functionality in postprocess for mapped doc
(cherry picked from commit 0691c7c7bc)

# Conflicts:
#	erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py
2026-08-05 12:31:22 +05:30
Mohd Haris
5764d1d173 fix: prevent TimestampMismatchError resolving Dunning with multiple overdue installments
`get_linked_dunnings_as_per_state` joins Dunning to its Overdue Payment child
table without DISTINCT. When a Sales Invoice has more than one overdue
installment, its Dunning holds one Overdue Payment row per installment, so the
query returns the same Dunning name once per row.

`update_linked_dunnings` then loads that Dunning name into a separate document
object for each duplicate row and saves each one. The first save bumps the
`modified` timestamp, so the second (now stale) save fails with
`TimestampMismatchError` ("Document has been modified after you have opened
it"). The error is raised on the Dunning while the user is submitting a Payment
Entry, making it confusing, and payments for such invoices cannot be posted at
all.

Add DISTINCT so each linked Dunning is returned (and saved) exactly once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 06bfc23436)
2026-08-04 07:34:27 +00:00
285 changed files with 162111 additions and 24078 deletions

View File

@@ -0,0 +1,54 @@
name: Upload main.pot to Crowdin
on:
push:
branches:
- develop
- version-16-hotfix
paths:
- "erpnext/locale/main.pot"
workflow_dispatch:
concurrency:
group: crowdin-upload-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
upload-sources:
name: Upload sources from ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
- name: Checkout ${{ github.ref_name }}
uses: actions/checkout@v6
- name: Restore Crowdin cache
uses: actions/cache/restore@v6
with:
path: .crowdin
key: crowdin-${{ github.ref_name }}
restore-keys: crowdin-${{ github.ref_name }}-
- name: Upload main.pot to Crowdin
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
config: crowdin.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
crowdin_branch_name: "[frappe.erpnext] ${{ github.ref_name }}"
upload_sources_args: "--cache"
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Save Crowdin cache
uses: actions/cache/save@v6
if: always()
with:
path: .crowdin
key: crowdin-${{ github.ref_name }}-${{ github.run_id }}

View File

@@ -22,4 +22,4 @@ jobs:
pull-requests: write
steps:
- uses: alyf-de/po-review-action@v1.1.0
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1

View File

@@ -13,6 +13,7 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- '**.po'
permissions:
contents: read

View File

@@ -13,6 +13,7 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- '**.po'
schedule:
# Run everday at midnight UTC / 5:30 IST
- cron: "0 0 * * *"

View File

@@ -24,10 +24,10 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.17.0",
"frappe-react-sdk": "^1.17.1",
"fuse.js": "^7.3.0",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.2",
"jotai-family": "^1.1.0",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.6.1",
@@ -39,7 +39,7 @@
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^8.1.0",
"react-router": "^8.3.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
@@ -48,18 +48,18 @@
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.16"
"vite": "^8.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.5",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.62.1"
"typescript-eslint": "^8.67.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,5 @@
preserve_hierarchy: true
files:
- source: /erpnext/locale/main.pot
translation: /erpnext/locale/%two_letters_code%.po
pull_request_title: "fix: sync translations from crowdin"
pull_request_labels:
- translation
- skip-release-notes
pull_request_reviewers:
- barredterra # change to your GitHub username if you copied this file
commit_message: "fix: %language% translations"
append_commit_message: false
languages_mapping:
two_letters_code:
pt-BR: pt_BR

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.32.3"
__version__ = "16.34.1"
def get_default_company(user=None):

View File

@@ -16,6 +16,8 @@ frappe.ui.form.on("Accounting Dimension", {
return {
filters: {
name: ["not in", invalid_doctypes],
istable: 0,
issingle: 0,
},
};
});

View File

@@ -60,6 +60,14 @@ class AccountingDimension(Document):
msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type)
frappe.throw(msg)
meta = frappe.get_meta(self.document_type)
if meta.istable or meta.issingle:
frappe.throw(
_(
"{0} cannot be used as an accounting dimension as it is not a standalone document type."
).format(frappe.bold(self.document_type))
)
exists = frappe.db.get_value("Accounting Dimension", {"document_type": self.document_type}, ["name"])
if exists and self.is_new():

View File

@@ -52,6 +52,23 @@ class TestAccountingDimension(ERPNextTestSuite):
self.assertEqual(gle.get("department"), "_Test Department - _TC")
self.assertEqual(gle1.get("department"), "_Test Department - _TC")
def test_child_table_not_allowed_as_dimension(self):
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Sales Team"})
self.assertRaises(frappe.ValidationError, dimension.insert)
def test_single_doctype_not_allowed_as_dimension(self):
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Selling Settings"})
self.assertRaises(frappe.ValidationError, dimension.insert)
def test_non_scalar_dimension_value_skipped_in_gl_dict(self):
si = create_sales_invoice(do_not_save=1)
si.department = "_Test Department - _TC"
self.assertEqual(si.get_gl_dict({}).get("department"), "_Test Department - _TC")
si.department = ["_Test Department - _TC"]
self.assertNotIn("department", si.get_gl_dict({}))
def test_mandatory(self):
location = frappe.get_doc("Accounting Dimension", "Location")
location.dimension_defaults[0].mandatory_for_bs = True

View File

@@ -94,13 +94,14 @@
"column_break_25",
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"receivable_payable_remarks_length",
"disable_include_dimensions",
"column_break_lvjk",
"general_ledger_remarks_length",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"default_ageing_range",
"column_break_ntmi",
"receivable_payable_remarks_length",
"legacy_section",
"ignore_is_opening_check_for_reporting",
"tab_break_dpet",
@@ -198,10 +199,12 @@
},
{
"default": "1",
"description": "System will use the latest saved Currency Exchange rate on or before the transaction date, however old it is. <br>\nUncheck to ignore rates older than Stale Days and fetch a fresh rate from the exchange rate provider instead.",
"fieldname": "allow_stale",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Allow Stale Exchange Rates"
"label": "Allow Stale Exchange Rates",
"show_description_on_click": 1
},
{
"default": "1",
@@ -277,10 +280,10 @@
},
{
"default": "0",
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
"description": "Enabling this option prevents the creation of a new Sales Invoice when the customer has an overdue limit set and their outstanding overdue amount exceeds that limit.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Restrict Customer Over Billing"
"label": "Prevent Sales Invoice when Customer is Overdue"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
@@ -474,7 +477,7 @@
{
"fieldname": "remarks_section",
"fieldtype": "Section Break",
"label": "Remarks Column Length"
"label": "General Ledger Report"
},
{
"default": "0",
@@ -548,7 +551,7 @@
{
"fieldname": "accounts_receivable_payable_tuning_section",
"fieldtype": "Section Break",
"label": "Accounts Receivable / Payable Tuning"
"label": "Accounts Receivable / Payable Report"
},
{
"fieldname": "legacy_section",
@@ -780,6 +783,12 @@
"fieldname": "book_stock_expense_gl_entries",
"fieldtype": "Check",
"label": "Book Stock Expense GL Entries"
},
{
"default": "0",
"fieldname": "disable_include_dimensions",
"fieldtype": "Check",
"label": "Disable \"Consider Accounting Dimension\" Filter"
}
],
"grid_page_length": 50,
@@ -788,7 +797,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-07-27 12:00:00.000000",
"modified": "2026-08-14 15:26:49.070889",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

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

View File

@@ -167,9 +167,10 @@ def get_transaction_reference(txn_data: dict) -> str:
).strip()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def convert_mt940_to_csv(data_import, mt940_file_path):
doc = frappe.get_doc("Bank Statement Import", data_import)
doc.check_permission("write")
_file_doc, content = get_file(mt940_file_path)
@@ -234,26 +235,30 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
@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):
job_id = frappe.get_doc("Bank Statement Import", data_import).start_import()
return job_id is not None
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 is_mt940_format(content: str) -> bool:
@@ -392,6 +397,7 @@ def get_import_status(docname):
import_status = {}
data_import = frappe.get_doc("Bank Statement Import", docname)
data_import.check_permission()
import_status["status"] = data_import.status
logs = frappe.get_all(

View File

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

@@ -275,6 +275,7 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
.join(overdue_payment)
.on(overdue_payment.parent == dunning.name)
.select(dunning.name)
.distinct()
.where(
(dunning.status == state)
& (dunning.docstatus != 2)

View File

@@ -122,6 +122,41 @@ class TestDunning(ERPNextTestSuite):
self.assertEqual(sales_invoice.status, "Overdue")
self.assertEqual(dunning.status, "Unresolved")
def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self):
"""
When an invoice has more than one overdue installment, its Dunning holds one
Overdue Payment row per installment. Submitting a Payment Entry for the invoice
must resolve the Dunning without raising a TimestampMismatchError caused by the
same Dunning being loaded and saved more than once.
"""
create_payment_terms_template_for_dunning()
# Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue.
sales_invoice = create_sales_invoice_against_cost_center(
posting_date=add_days(today(), -15),
qty=1,
rate=100,
do_not_submit=True,
)
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
sales_invoice.submit()
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
# Two overdue installments -> two overdue payment rows for the same invoice.
self.assertEqual(len(dunning.overdue_payments), 2)
dunning.submit()
self.assertEqual(dunning.status, "Unresolved")
# Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning.
pe = get_payment_entry("Sales Invoice", sales_invoice.name)
pe.reference_no, pe.reference_date = "3", nowdate()
pe.insert()
pe.submit()
sales_invoice.reload()
dunning.reload()
self.assertEqual(sales_invoice.outstanding_amount, 0)
self.assertEqual(dunning.status, "Resolved")
def test_dunning_resolution_from_credit_note(self):
"""
Test that dunning is resolved when a credit note is issued against the original invoice.

View File

@@ -31,6 +31,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
AccountFilterValidator,
CalculationFormulaValidator,
DependencyValidator,
get_valid_api_method,
)
from erpnext.accounts.report.financial_statements import (
get_columns,
@@ -1164,10 +1165,12 @@ class RowProcessor:
def _process_api_row(self, row) -> RowData:
api_path = row.calculation_formula
# TODO
method = get_valid_api_method(api_path)
try:
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
if row.reverse_sign:
values = [-1 * v for v in values]

View File

@@ -163,7 +163,7 @@ function show_accounts_tree(template_rows, has_selection) {
fieldname: "company",
fieldtype: "Link",
options: "Company",
label: "Company",
label: __("Company"),
reqd: 1,
default: frappe.defaults.get_user_default("Company"),
onchange: () => {
@@ -176,7 +176,7 @@ function show_accounts_tree(template_rows, has_selection) {
fieldname: "view_type",
fieldtype: "Select",
options: ["Missing Accounts", "Filtered Accounts"],
label: "View",
label: __("View"),
default: has_selection ? "Filtered Accounts" : "Missing Accounts",
reqd: 1,
onchange: () => {
@@ -192,10 +192,10 @@ function show_accounts_tree(template_rows, has_selection) {
{
fieldname: "tip",
fieldtype: "HTML",
label: "Tip",
label: __("Tip"),
options: `
<div class="alert alert-success" role="alert">
Tip: Select report lines to view their accounts
${__("Tip: Select report lines to view their accounts")}
</div>
`,
depends_on: has_selection ? "eval: false" : "eval: true",
@@ -203,7 +203,7 @@ function show_accounts_tree(template_rows, has_selection) {
{
fieldname: "tree_area",
fieldtype: "HTML",
label: "Chart of Accounts",
label: __("Chart of Accounts"),
read_only: 1,
depends_on: "eval: doc.company",
},
@@ -288,14 +288,14 @@ function update_formula_label(frm, data_source) {
if (!field) return;
const labels = {
"Account Data": "Account Filter",
"Custom API": "API Method Path",
"Account Data": __("Account Filter"),
"Custom API": __("API Method Path"),
};
grid.update_docfield_property(
"calculation_formula",
"label",
labels[data_source] || "Calculation Formula"
labels[data_source] || __("Calculation Formula")
);
}
@@ -370,7 +370,7 @@ function update_formula_description(frm, data_source) {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Custom API Setup</h5>
<p ${text_style}>Path to your custom method that returns financial data.</p>
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
<h6 ${subtitle_style}>Format:</h6>
<ul ${list_style}>
@@ -380,7 +380,8 @@ function update_formula_description(frm, data_source) {
<h6 ${subtitle_style}>Method Signature:</h6>
<div ${code_style}>
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br>&nbsp; # filters: dict — report filters (company, period, etc.) <br>&nbsp; # periods: list[dict] — period definitions <br>&nbsp; # row: dict — the current report row <br><br>&nbsp; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
<!-- &#10; is used for line breaks since frappe.render replaces newlines with spaces -->
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"])&#10;def get_custom_data(filters, periods, row):&#10; # filters: dict — report filters (company, period, etc.)&#10; # periods: list[dict] — period definitions&#10; # row: dict — the current report row&#10;&#10; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
</div>
<h6 ${subtitle_style}>Return Format:</h6>

View File

@@ -10,18 +10,41 @@ from enum import Enum
from typing import Any, ClassVar
import frappe
from frappe import _
from frappe import _, is_whitelisted
from frappe.database.operator_map import OPERATOR_MAP
from frappe.database.query import SQLFunctionParser
def get_valid_api_method(api_path: str):
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
method = frappe.get_attr(api_path)
is_whitelisted(method)
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
frappe.throw(
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
frappe.PermissionError,
title=_("Method Not Allowed"),
)
return method
def get_formula_field_label(data_source: str) -> str:
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
labels = {
"Account Data": _("Account Filter"),
"Custom API": _("API Method Path"),
}
return labels.get(data_source, _("Calculation Formula"))
@dataclass
class ValidationIssue:
"""Represents a single validation issue"""
message: str
row_idx: int | None = None
field: str | None = None
details: dict[str, Any] = None
def __post_init__(self):
@@ -29,10 +52,9 @@ class ValidationIssue:
self.details = {}
def __str__(self) -> str:
prefix = f"Row {self.row_idx}: " if self.row_idx else ""
field_info = f"[{self.field}] " if self.field else ""
message = f"{prefix}{field_info}{self.message}"
return _(message)
if self.row_idx:
return _("Row {0}: {1}", context="Financial Report Template").format(self.row_idx, self.message)
return self.message
@dataclass
@@ -134,7 +156,9 @@ class TemplateStructureValidator(Validator):
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
result.add_error(
ValidationIssue(
message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens",
message=_(
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
).format(ref_code),
row_idx=row.idx,
)
)
@@ -143,7 +167,7 @@ class TemplateStructureValidator(Validator):
if ref_code in used_codes:
result.add_error(
ValidationIssue(
message=f"Duplicate line reference: '{ref_code}'",
message=_("Duplicate line reference: '{0}'").format(ref_code),
row_idx=row.idx,
)
)
@@ -159,7 +183,7 @@ class TemplateStructureValidator(Validator):
if row.data_source == "Account Data" and not row.balance_type:
result.add_error(
ValidationIssue(
message="Balance Type is required for Account Data",
message=_("Balance Type is required for Account Data"),
row_idx=row.idx,
)
)
@@ -169,7 +193,9 @@ class TemplateStructureValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=f"Formula is required for {row.data_source}",
message=_("{0} is required for {1}").format(
get_formula_field_label(row.data_source), row.data_source
),
row_idx=row.idx,
)
)
@@ -226,7 +252,7 @@ class DependencyValidator(Validator):
cycle = [*path[cycle_start:], node]
result.add_error(
ValidationIssue(
message=f"Circular dependency detected: {''.join(cycle)}",
message=_("Circular dependency detected: {0}").format("".join(cycle)),
)
)
return
@@ -258,7 +284,7 @@ class DependencyValidator(Validator):
row_idx = self._get_row_idx(ref_code)
result.add_error(
ValidationIssue(
message=f"Line References undefined in Formula: {', '.join(undefined)}",
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
row_idx=row_idx,
)
)
@@ -288,9 +314,10 @@ class CalculationFormulaValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message="Formula is required for Calculated Amount",
message=_("{0} is required for Calculated Amount").format(
get_formula_field_label(row.data_source)
),
row_idx=row.idx,
field="Formula",
)
)
return result
@@ -302,7 +329,7 @@ class CalculationFormulaValidator(Validator):
if not self._are_parentheses_balanced(formula):
result.add_error(
ValidationIssue(
message="Formula has unbalanced parentheses",
message=_("Formula has unbalanced parentheses"),
row_idx=row.idx,
)
)
@@ -314,7 +341,7 @@ class CalculationFormulaValidator(Validator):
if row.reference_code and row.reference_code in refs:
result.add_error(
ValidationIssue(
message=f"Formula references itself ('{row.reference_code}')",
message=_("Formula references itself ('{0}')").format(row.reference_code),
row_idx=row.idx,
)
)
@@ -324,7 +351,7 @@ class CalculationFormulaValidator(Validator):
if undefined:
result.add_error(
ValidationIssue(
message=f"Formula references undefined codes: {', '.join(undefined)}",
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
row_idx=row.idx,
)
)
@@ -334,7 +361,7 @@ class CalculationFormulaValidator(Validator):
if eval_error:
result.add_error(
ValidationIssue(
message=f"Formula evaluation error: {eval_error}",
message=_("Formula evaluation error: {0}").format(eval_error),
row_idx=row.idx,
)
)
@@ -371,7 +398,7 @@ class CalculationFormulaValidator(Validator):
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
if not isinstance(result, (int, float)): # noqa: UP038
return f"Formula must return a numeric value, got {type(result).__name__}"
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
return None
except Exception as e:
@@ -394,9 +421,10 @@ class AccountFilterValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message="Account filter is required for Account Data",
message=_("{0} is required for Account Data").format(
get_formula_field_label(row.data_source)
),
row_idx=row.idx,
field="Formula",
)
)
return result
@@ -412,18 +440,18 @@ class AccountFilterValidator(Validator):
if error:
result.add_error(
ValidationIssue(
message=error,
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
row_idx=row.idx,
field="Account Filter",
)
)
except json.JSONDecodeError as e:
result.add_error(
ValidationIssue(
message=f"Invalid JSON format: {e!s}",
message=_("{0}: Invalid JSON format: {1}").format(
get_formula_field_label(row.data_source), str(e)
),
row_idx=row.idx,
field="Account Filter",
)
)
@@ -438,36 +466,36 @@ class AccountFilterValidator(Validator):
# simple condition: [field, operator, value]
if isinstance(filter_config, list):
if len(filter_config) != 3:
return "Filter must be [field, operator, value]"
return _("Filter must be [field, operator, value]")
field, operator, value = filter_config
if not isinstance(field, str) or not isinstance(operator, str):
return "Field and operator must be strings"
return _("Field and operator must be strings")
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
if field not in account_fields:
return f"Field '{display}' is not a valid Account field"
return _("Field '{0}' is not a valid Account field").format(display)
if operator.casefold() not in OPERATOR_MAP:
return f"Invalid operator '{operator}'"
return _("Invalid operator '{0}'").format(operator)
if operator in ["in", "not in"] and not isinstance(value, list):
return f"Operator '{operator}' requires a list value"
return _("Operator '{0}' requires a list value").format(operator)
# logical condition: {"and": [condition1, condition2]}
elif isinstance(filter_config, dict):
if len(filter_config) != 1:
return "Logical condition must have exactly one operator"
return _("Logical condition must have exactly one operator")
op = next(iter(filter_config.keys())).lower()
if op not in ["and", "or"]:
return "Logical operators must be 'and' or 'or'"
return _("Logical operators must be 'and' or 'or'")
conditions = filter_config[next(iter(filter_config.keys()))]
if not isinstance(conditions, list) or len(conditions) < 1:
return "Logical conditions need at least 1 sub-condition"
return _("Logical conditions need at least 1 sub-condition")
# recursive
for condition in conditions:
@@ -475,7 +503,7 @@ class AccountFilterValidator(Validator):
if error:
return error
else:
return "Filter must be a list or dict"
return _("Filter must be a list or dict")
return None
@@ -511,34 +539,31 @@ class FormulaValidator(Validator):
if "." not in api_path:
result.add_error(
ValidationIssue(
message="Custom API path should be in format: app.module.method",
message=_("{0} should be in format: app.module.method").format(
get_formula_field_label(row.data_source)
),
row_idx=row.idx,
field="Formula",
)
)
return result
# Method exists?
try:
module_path, method_name = api_path.rsplit(".", 1)
module = frappe.get_module(module_path)
if not hasattr(module, method_name):
result.add_error(
ValidationIssue(
message=f"Method '{method_name}' not found in module '{module_path}' (might be environment-specific)",
row_idx=row.idx,
field="Formula",
)
)
get_valid_api_method(api_path)
except Exception as e:
result.add_error(
ValidationIssue(
message=f"Could not validate API path: {e!s}",
row_idx=row.idx,
field="Formula",
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
frappe.clear_last_message()
if isinstance(e, frappe.PermissionError):
message = _("{0}: Method '{1}' must be whitelisted and permit GET requests").format(
get_formula_field_label(row.data_source), api_path
)
)
else:
message = _("Could not validate {0}: {1}").format(
get_formula_field_label(row.data_source), str(e)
)
result.add_error(ValidationIssue(message=message, row_idx=row.idx))
return result

View File

@@ -2,7 +2,12 @@
# For license information, please see license.txt
import frappe
from frappe.tests.utils import whitelist_for_tests
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
FormulaValidator,
get_valid_api_method,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -72,3 +77,90 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
)
return template
def not_whitelisted_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["POST"])
def whitelisted_post_only_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["GET"])
def whitelisted_get_method(**kwargs):
return [42.0]
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
"""Custom API rows must point to whitelisted methods that permit GET"""
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
def create_api_template(self, api_path):
template = self.create_test_template_with_rows(
[
{
"reference_code": "API001",
"display_name": "API Row",
"data_source": "Custom API",
"calculation_formula": api_path,
}
]
)
template.report_type = "Profit and Loss Statement"
return template
def test_get_valid_api_method(self):
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
def test_save_rejects_invalid_api_methods(self):
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
self.assertRaises(frappe.ValidationError, template.insert)
def test_save_allows_get_whitelisted_method(self):
template = self.create_api_template(self.WHITELISTED_GET)
template.insert()
template.delete()
def test_engine_rejects_invalid_api_methods(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
def test_engine_calls_valid_api_method(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
template = self.create_api_template(self.WHITELISTED_GET)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
row_data = processor._process_api_row(template.rows[0])
self.assertEqual(row_data.values, [42.0])
def test_validation_keeps_message_log_clean(self):
validator = FormulaValidator(frappe._dict(rows=[]))
message_count = len(frappe.local.message_log)
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
result = validator.validate(row)
self.assertFalse(result.is_valid)
self.assertEqual(len(frappe.local.message_log), message_count)

View File

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

View File

@@ -95,7 +95,7 @@ frappe.ui.form.on("Journal Entry", {
);
}
if (frm.doc.docstatus == 1) {
if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) {
frm.add_custom_button(
__("Reverse Journal Entry"),
function () {

View File

@@ -1402,6 +1402,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))
@@ -1447,6 +1448,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
@@ -1482,6 +1485,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"
)
@@ -1777,6 +1782,20 @@ def make_inter_company_journal_entry(name, voucher_type, company):
@frappe.whitelist()
def make_reverse_journal_entry(source_name, target_doc=None):
# `get_mapped_doc` checks this as well, but the guards below disclose which entry
# reverses which, so read access has to be settled before they run
if not frappe.has_permission("Journal Entry", doc=source_name):
frappe.throw(_("Not permitted"), frappe.PermissionError)
reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of")
if reversal_of:
frappe.throw(
_("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format(
get_link_to_form("Journal Entry", source_name),
get_link_to_form("Journal Entry", reversal_of),
)
)
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(

View File

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

View File

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

View File

@@ -297,6 +297,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()
if not frappe.in_test:

View File

@@ -4,9 +4,11 @@
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.account.test_account import create_account
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
from erpnext.tests.utils import ERPNextTestSuite
@@ -126,6 +128,55 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
@ERPNextTestSuite.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

@@ -2893,7 +2893,11 @@ def get_payment_entry(
reference_date=None,
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.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

@@ -92,6 +92,7 @@ frappe.ui.form.on("Payment Request", "is_a_subscription", function (frm) {
freeze: true,
callback: function (data) {
if (!data.exc) {
frm.clear_table("subscription_plans");
$.each(data.message || [], function (i, v) {
var d = frappe.model.add_child(
frm.doc,

View File

@@ -711,6 +711,7 @@ def make_payment_request(**args):
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
party_account_currency = get_account_currency(party_account)
subscription_plans = get_subscription_details(ref_doc.doctype, ref_doc.name)
pr.update(
{
"payment_gateway_account": gateway_account.get("name"),
@@ -742,12 +743,24 @@ def make_payment_request(**args):
or gateway_account.get("payment_channel", "Email") != "Email"
),
"phone_number": args.get("phone_number") if args.get("phone_number") else None,
"is_a_subscription": 1 if subscription_plans else 0,
}
)
if selected_payment_schedules:
apply_payment_references(pr, payment_reference)
if subscription_plans:
pr.set(
"subscription_plans",
[
{
"plan": row.plan,
"qty": row.qty,
}
for row in subscription_plans
],
)
# Dimensions
pr.update(
{
@@ -1061,19 +1074,25 @@ def get_dummy_message(doc):
@frappe.whitelist()
def get_subscription_details(reference_doctype, reference_name):
if reference_doctype == "Sales Invoice":
subscriptions = frappe.db.sql(
"""SELECT parent as sub_name FROM `tabSubscription Invoice` WHERE invoice=%s""",
reference_name,
as_dict=1,
)
subscription_plans = []
for subscription in subscriptions:
plans = frappe.get_doc("Subscription", subscription.sub_name).plans
for plan in plans:
subscription_plans.append(plan)
return subscription_plans
def get_subscription_details(reference_doctype: str, reference_name: str) -> list[dict]:
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
if not frappe.get_meta(reference_doctype).has_field("subscription"):
return []
subscription = frappe.db.get_value(reference_doctype, reference_name, "subscription")
if not subscription:
return []
return frappe.get_all(
"Subscription Plan Detail",
filters={"parent": subscription, "parenttype": "Subscription", "parentfield": "plans"},
fields=[
"plan",
"qty",
],
)
@frappe.whitelist()
@@ -1169,6 +1188,7 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
@frappe.whitelist()
def get_available_payment_schedules(reference_doctype, reference_name):
ref_doc = frappe.get_doc(reference_doctype, reference_name)
ref_doc.check_permission()
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
return []

View File

@@ -261,12 +261,15 @@ def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
def get_invoices(start, end, pos_profile, user):
invoice_doctype = frappe.db.get_single_value("POS Settings", "invoice_type")
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
frappe.has_permission("Sales Invoice", throw=True)
sales_inv_query = build_invoice_query("Sales Invoice", user, pos_profile, start, end)
query = sales_inv_query
if invoice_doctype == "POS Invoice":
frappe.has_permission("POS Invoice", throw=True)
pos_inv_query = build_invoice_query("POS Invoice", user, pos_profile, start, end)
query = query + pos_inv_query

View File

@@ -497,6 +497,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",
@@ -1636,7 +1637,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:22:50.253090",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -280,40 +280,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,8 +12,9 @@
{
"fieldname": "fieldname",
"fieldtype": "Data",
"hidden": 1,
"label": "Fieldname"
"in_list_view": 1,
"label": "Fieldname",
"read_only": 1
},
{
"fieldname": "field",
@@ -26,7 +27,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-07-29 18:08:40.323579",
"modified": "2026-08-31 20:41:12.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Search Fields",

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) {
@@ -42,74 +11,97 @@ frappe.ui.form.on("POS Settings", {
frm.trigger("add_search_options");
},
invoice_type: function (frm) {
frm.trigger("get_invoice_fields");
},
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 invoice_type = frm.doc.invoice_type;
if (!invoice_type) return;
frappe.model.with_doctype(invoice_type, () => {
// the invoice type can change again while the meta loads
if (frm.doc.invoice_type !== invoice_type) return;
const fields = frappe.get_doc("DocType", invoice_type).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 };
})
)
);
frm.trigger("validate_invoice_fields");
});
},
validate_invoice_fields: function (frm) {
const valid_fieldnames = frappe
.get_doc("DocType", frm.doc.invoice_type)
.fields.filter(is_valid_invoice_field)
.map((df) => df.fieldname);
const invalid_fields = (frm.doc.invoice_fields || [])
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
.map((row) => `#${row.idx} ${row.fieldname}`);
if (!invalid_fields.length) return;
frappe.msgprint({
title: __("Invalid POS Fields"),
indicator: "orange",
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
frm.doc.invoice_type.bold(),
invalid_fields.join(", "),
]),
});
},
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");
},
});
frappe.ui.form.on("POS Field", {
fieldname: function (frm, doctype, name) {
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
return doc.fieldname == d.fieldname ? d : null;
})[0];
const doc = frappe.get_doc(doctype, name);
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
if (!df) return;
doc.label = df.label;
doc.reqd = df.reqd;
doc.options = df.options;
doc.fieldtype = df.fieldtype;
doc.default_value = df.default;
frm.refresh_field("fields");
frm.refresh_field("invoice_fields");
},
});

View File

@@ -5,8 +5,46 @@ 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
@@ -32,17 +70,10 @@ class POSSettings(Document):
if old_doc.invoice_type != self.invoice_type:
self.validate_invoice_type()
self.validate_duplicate_invoice_fields()
self.validate_invoice_fields()
def validate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
self.validate_duplicate_pos_search_fields()
self.validate_pos_search_fields()
def validate_invoice_type(self):
pos_opening_entries_count = frappe.db.count(
@@ -55,3 +86,94 @@ class POSSettings(Document):
),
title=_("Invoice Document Type Selection Error"),
)
def validate_duplicate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
def validate_invoice_fields(self):
if not self.invoice_type:
return
meta = frappe.get_meta(self.invoice_type)
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(_(self.invoice_type))
),
)
# 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]
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
for fieldname in duplicate_fieldnames:
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

@@ -2,8 +2,135 @@
# See license.txt
import unittest
import frappe
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
from erpnext.tests.utils import ERPNextTestSuite
class TestPOSSettings(ERPNextTestSuite):
pass
def setUp(self):
self.settings = frappe.get_single("POS Settings")
self.settings.invoice_fields = []
self.settings.pos_search_fields = []
def assertInvalid(self, message):
with self.assertRaises(frappe.ValidationError) as context:
self.settings.save()
self.assertIn(message, str(context.exception))
def test_invoice_field_is_validated_against_invoice_type(self):
# consolidated_invoice exists on POS Invoice only
self.settings.invoice_type = "POS Invoice"
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
self.settings.save()
self.settings.invoice_type = "Sales Invoice"
self.assertInvalid("is not a valid field of")
def test_field_common_to_both_invoice_types_is_allowed(self):
for invoice_type in ("POS Invoice", "Sales Invoice"):
self.settings.invoice_type = invoice_type
self.settings.invoice_fields = []
self.settings.append("invoice_fields", {"fieldname": "po_no"})
self.settings.save()
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)")
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
self.settings.invoice_type = None
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.settings.save()
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")

View File

@@ -142,6 +142,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()
@@ -157,6 +159,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()
@@ -171,6 +175,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

@@ -504,6 +504,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",
@@ -1703,7 +1704,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

@@ -42,6 +42,7 @@ from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
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.doctype.purchase_receipt.purchase_receipt import (
update_billed_amount_based_on_po,
)
@@ -286,9 +287,7 @@ class PurchaseInvoice(BuyingController):
self.check_conversion_rate()
self.validate_credit_to_acc()
self.clear_unallocated_advances("Purchase Invoice Advance", "advances")
self.check_for_on_hold_or_closed_status(
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
)
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
self.validate_with_previous_doc()
self.validate_uom_is_integer("uom", "qty")
self.validate_uom_is_integer("stock_uom", "stock_qty")
@@ -300,6 +299,7 @@ class PurchaseInvoice(BuyingController):
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
self.set_status()
self.validate_purchase_receipt_if_update_stock()
self.validate_exchange_rate_with_purchase_receipt()
validate_inter_company_party(
self.doctype, self.supplier, self.company, self.inter_company_invoice_reference
)
@@ -323,6 +323,47 @@ class PurchaseInvoice(BuyingController):
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_exchange_rate_with_purchase_receipt(self):
if self.is_internal_transfer() or not erpnext.is_perpetual_inventory_enabled(self.company):
return
stock_items = self.get_stock_items()
receipts = {
item.purchase_receipt
for item in self.items
if item.purchase_receipt and item.item_code in stock_items
}
if not receipts:
return
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"):
return
mismatched = [
f"{frappe.bold(row.name)} ({row.conversion_rate})"
for row in frappe.get_all(
"Purchase Receipt",
filters={"name": ("in", list(receipts))},
fields=["name", "currency", "conversion_rate"],
)
if row.currency == self.currency
and flt(row.conversion_rate)
and flt(row.conversion_rate) != flt(self.conversion_rate)
]
if not mismatched:
return
frappe.throw(
_(
"Exchange rate {0} does not match the exchange rate of Purchase Receipt {1}. Use the same exchange rate as the Purchase Receipt or enable {2} in {3} to adjust the landed cost based on this invoice."
).format(
frappe.bold(self.conversion_rate),
", ".join(mismatched),
frappe.bold(_("Set Landed Cost Based on Purchase Invoice Rate")),
get_link_to_form("Buying Settings", "Buying Settings", _("Buying Settings")),
)
)
def validate_invoice_hold(self):
if self.is_return:
frappe.throw(_("Return Purchase Invoice cannot be held."))
@@ -1752,9 +1793,7 @@ class PurchaseInvoice(BuyingController):
super().on_cancel()
PurchaseTaxWithholding(self).on_cancel()
self.check_for_on_hold_or_closed_status(
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
)
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
if self.is_return and not self.update_billed_amount_in_purchase_order:
# NOTE status updating bypassed for is_return
@@ -2081,6 +2120,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)
@@ -2104,15 +2148,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
or {}
)
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("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 - flt(returned_qty_map.get("qty"))
target.received_qty = pending_qty
target.stock_qty = (pending_qty - flt(returned_qty_map.get("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", [])
@@ -2142,7 +2184,9 @@ 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

@@ -507,6 +507,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
@@ -518,25 +524,15 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 80
self.assertRaises(frappe.ValidationError, pi.insert)
pi.conversion_rate = 70
pi.insert()
pi.submit()
# Get exchnage gain and loss account
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
# fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[0].base_net_amount - pr.items[0].base_net_amount
)
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
frappe.db.set_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
self.assertFalse(
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
@@ -544,7 +540,17 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
# Creating Purchase Invoice with USD currency
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = frappe.new_doc("Purchase Receipt")
pr.currency = "USD"
pr.company = "_Test Company with perpetual inventory"
@@ -558,34 +564,20 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"rate": 100,
},
)
pr.append(
"items",
{"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"},
)
pr.insert()
pr.submit()
# Createing purchase invoice against Purchase Receipt
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 80
pi.credit_to = "_Test Payable USD - TCP1"
pi.insert()
pi.submit()
# Get exchnage gain and loss account
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
# fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "credit"
self.assertFalse(
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[1].base_net_amount - pr.items[1].base_net_amount
)
self.assertEqual(flt(discrepancy_caused_by_exchange_rate_diff, 2), amount)
def test_purchase_invoice_change_naming_series(self):
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][1])
pi.insert()

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

@@ -612,6 +612,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",
@@ -2364,7 +2365,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-05-28 12:15:12.486443",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, query_builder, scrub
from frappe.database.schema import get_definition
from frappe.permissions import get_allowed_docs_for_doctype
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
@@ -52,6 +53,7 @@ class ReceivablePayableReport:
self.filters = frappe._dict(filters or {})
self.qb_selection_filter = []
self.ple = qb.DocType("Payment Ledger Entry")
self.sales_person_records = None
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
@@ -92,6 +94,7 @@ class ReceivablePayableReport:
self.party_type = get_party_types_from_account_type(self.account_type)
self.party_details = {}
self.invoices = set()
self.sales_person_records = None
self.skip_total_row = 0
self.advance_payment_doctypes = get_advance_payment_doctypes()
@@ -206,7 +209,7 @@ class ReceivablePayableReport:
def get_invoices(self, ple):
if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"):
if self.filters.get("sales_person"):
if self.sales_person_records is not None:
if ple.voucher_no in self.sales_person_records.get(
"Sales Invoice", []
) or ple.party in self.sales_person_records.get("Customer", []):
@@ -237,7 +240,7 @@ class ReceivablePayableReport:
]
def get_voucher_balance(self, ple):
if self.filters.get("sales_person"):
if self.sales_person_records is not None:
if not (
ple.party in self.sales_person_records.get("Customer", [])
or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", [])
@@ -896,25 +899,54 @@ class ReceivablePayableReport:
self.ple_query = query
def get_permitted_sales_persons(self, parenttype):
if self.account_type != "Receivable":
return None
permissions = frappe.permissions.get_user_permissions(frappe.session.user).get("Sales Person", [])
if not permissions:
return None
return get_allowed_docs_for_doctype(permissions, parenttype)
def get_sales_invoices_or_customers_based_on_sales_person(self):
parenttypes = ["Customer", "Sales Invoice"]
permitted = {p: self.get_permitted_sales_persons(p) for p in parenttypes}
if not (self.filters.get("sales_person") or any(p is not None for p in permitted.values())):
return
steam = frappe.qb.DocType("Sales Team")
scope = []
for parenttype in parenttypes:
criterion = steam.parenttype == parenttype
if (allowed := permitted[parenttype]) is not None:
criterion &= steam.sales_person.isin(allowed or [""])
scope.append(criterion)
conditions = [Criterion.any(scope)]
if self.filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
# nosemgrep
records = frappe.db.sql(
"""
select distinct parent, parenttype
from `tabSales Team` steam
where parenttype in ('Customer', 'Sales Invoice')
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
""",
(lft, rgt),
as_dict=1,
sp = frappe.qb.DocType("Sales Person")
conditions.append(
steam.sales_person.isin(
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
)
)
self.sales_person_records = frappe._dict()
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
records = (
frappe.qb.from_(steam)
.select(steam.parent, steam.parenttype)
.distinct()
.where(Criterion.all(conditions))
.run(as_dict=1)
)
self.sales_person_records = frappe._dict()
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
def get_invoices_based_on_sales_partner(self):
if not self.filters.get("sales_partner"):

View File

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

View File

@@ -510,6 +510,7 @@ class GrossProfitGenerator:
self.average_buying_rate = {}
self.filters = frappe._dict(filters)
self.load_invoice_items()
self.load_drop_ship_buying_rates()
self.get_delivery_notes()
self.load_product_bundle()
@@ -518,6 +519,7 @@ class GrossProfitGenerator:
self.load_non_stock_items()
self.get_returned_invoice_items()
self.allocate_legacy_return_items()
self.process()
def process(self):
@@ -534,6 +536,8 @@ class GrossProfitGenerator:
base_amount = 0
for row in reversed(self.si_list):
sales_invoice_item = row.item_row
if self.filters.get("group_by") == "Monthly":
row.monthly = formatdate(row.posting_date, "MMM YYYY")
@@ -596,7 +600,7 @@ class GrossProfitGenerator:
row.buying_rate, row.base_rate = 0.0, 0.0
if self.is_not_invoice_row(row):
self.update_return_invoices(row)
self.update_return_invoices(row, sales_invoice_item)
if grouped_by_invoice and row.indent == 1.0:
buying_amount += row.buying_amount
@@ -623,26 +627,32 @@ class GrossProfitGenerator:
if self.grouped:
self.get_average_rate_based_on_group_by()
def update_return_invoices(self, row):
if row.parent in self.returned_invoices and row.item_code in self.returned_invoices[row.parent]:
returned_item_rows = self.returned_invoices[row.parent][row.item_code]
for returned_item_row in returned_item_rows:
# returned_items 'qty' should be stateful
if returned_item_row.qty != 0:
if row.qty >= abs(returned_item_row.qty):
row.qty += returned_item_row.qty
row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
returned_item_row.qty = 0
returned_item_row.base_amount = 0
def update_return_invoices(self, row, sales_invoice_item):
returned_item_rows = self.returned_invoices.get(row.parent, {}).get(sales_invoice_item)
if not returned_item_rows:
return
else:
row.qty = 0
row.base_amount = 0
returned_item_row.qty += row.qty
returned_item_row.base_amount += row.base_amount
for returned_item_row in returned_item_rows:
# returned_items 'qty' should be stateful
if returned_item_row.qty != 0:
if row.qty >= abs(returned_item_row.qty):
row.qty += returned_item_row.qty
row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
returned_item_row.qty = 0
returned_item_row.base_amount = 0
if not row.delivered_by_supplier:
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
else:
returned_item_row.qty += row.qty
returned_item_row.base_amount += row.base_amount
row.qty = 0
row.base_amount = 0
if row.delivered_by_supplier:
buying_amount = self.get_drop_ship_buying_amount(row)
if buying_amount is not None:
row.buying_amount = flt(buying_amount, self.currency_precision)
else:
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
def get_average_rate_based_on_group_by(self):
for key in list(self.grouped):
@@ -723,7 +733,8 @@ class GrossProfitGenerator:
returned_invoices = frappe.db.sql(
"""
select
si.name, si_item.item_code, si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against
si.name, si_item.item_code, si_item.sales_invoice_item, si_item.stock_qty as qty,
si_item.base_net_amount as base_amount, si.return_against
from
`tabSales Invoice` si, `tabSales Invoice Item` si_item
where
@@ -737,17 +748,73 @@ class GrossProfitGenerator:
)
self.returned_invoices = frappe._dict()
self.legacy_returned_invoices = frappe._dict()
for inv in returned_invoices:
self.returned_invoices.setdefault(inv.return_against, frappe._dict()).setdefault(
inv.item_code, []
invoice_returns = (
self.returned_invoices if inv.sales_invoice_item else self.legacy_returned_invoices
)
invoice_returns.setdefault(inv.return_against, frappe._dict()).setdefault(
inv.sales_invoice_item or inv.item_code, []
).append(inv)
def skip_row(self, row):
if self.filters.get("group_by") != "Invoice":
if not row.get(scrub(self.filters.get("group_by", ""))):
return True
def allocate_legacy_return_items(self):
source_invoice_items = {}
for row in reversed(self.si_list):
if row.is_return or not row.parent or self.skip_row(row):
continue
return False
source_invoice_items.setdefault((row.parent, row.item_code), {}).setdefault(row.item_row, row.qty)
for invoice, legacy_invoice_items in self.legacy_returned_invoices.items():
returned_invoice_items = self.returned_invoices.setdefault(invoice, frappe._dict())
for item_code, legacy_item_rows in legacy_invoice_items.items():
targets = self.get_legacy_return_targets(
source_invoice_items.get((invoice, item_code), {}), returned_invoice_items
)
for legacy_item_row in legacy_item_rows:
self.allocate_legacy_return_item(legacy_item_row, targets, returned_invoice_items)
def get_legacy_return_targets(self, source_invoice_items, returned_invoice_items):
targets = []
for item_row, qty in source_invoice_items.items():
linked_return_qty = sum(
flt(returned_item.qty) for returned_item in returned_invoice_items.get(item_row, [])
)
if available_qty := max(flt(qty) + linked_return_qty, 0):
targets.append(frappe._dict(item_row=item_row, available_qty=available_qty))
targets.sort(key=lambda target: bool(returned_invoice_items.get(target.item_row)))
return targets
def allocate_legacy_return_item(self, legacy_item_row, targets, returned_invoice_items):
remaining_qty = abs(flt(legacy_item_row.qty))
remaining_base_amount = flt(legacy_item_row.base_amount)
if not remaining_qty:
return
qty_sign = -1 if legacy_item_row.qty < 0 else 1
for target in targets:
if not target.available_qty:
continue
allocated_qty = min(target.available_qty, remaining_qty)
allocated_item_row = frappe._dict(legacy_item_row.copy())
allocated_item_row.qty = qty_sign * allocated_qty
allocated_item_row.base_amount = remaining_base_amount * allocated_qty / remaining_qty
returned_invoice_items.setdefault(target.item_row, []).append(allocated_item_row)
target.available_qty -= allocated_qty
remaining_qty -= allocated_qty
remaining_base_amount -= allocated_item_row.base_amount
if not remaining_qty:
break
def skip_row(self, row):
group_by = self.filters.get("group_by")
if group_by in {"Invoice", "Monthly"}:
return False
return not row.get(scrub(group_by))
def get_buying_amount_from_product_bundle(self, row, product_bundle):
buying_amount = 0.0
@@ -781,28 +848,12 @@ class GrossProfitGenerator:
# IMP NOTE
# stock_ledger_entries should already be filtered by item_code and warehouse and
# sorted by posting_date desc, posting_time desc
if (
row.delivered_by_supplier
and row.so_detail
and (
po_details := frappe.get_all(
"Purchase Order Item",
filters={"sales_order_item": row.so_detail, "docstatus": 1},
pluck="name",
)
)
):
from frappe.query_builder.functions import Sum
if row.delivered_by_supplier:
buying_amount = self.get_drop_ship_buying_amount(row)
if buying_amount is not None:
return buying_amount
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty * table.base_net_rate))
.where((table.po_detail.isin(po_details)) & (table.docstatus == 1))
)
return flt(query.run()[0][0])
elif item_code in self.non_stock_items and (row.project or row.cost_center):
if item_code in self.non_stock_items and (row.project or row.cost_center):
# Issue 6089-Get last purchasing rate for non-stock item
item_rate = self.get_last_purchase_rate(item_code, row)
return flt(row.qty) * item_rate
@@ -833,6 +884,49 @@ class GrossProfitGenerator:
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
def load_drop_ship_buying_rates(self):
self.drop_ship_buying_rates = {}
sales_order_items = {
row.so_detail for row in self.si_list if row.delivered_by_supplier and row.so_detail
}
if not sales_order_items:
return
from frappe.query_builder.functions import Sum
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
buying_amounts = (
frappe.qb.from_(purchase_order_item)
.left_join(purchase_invoice_item)
.on(
(purchase_invoice_item.po_detail == purchase_order_item.name)
& (purchase_invoice_item.docstatus == 1)
)
.select(
purchase_order_item.sales_order_item,
Sum(purchase_invoice_item.qty * purchase_invoice_item.base_net_rate).as_("buying_amount"),
Sum(purchase_invoice_item.stock_qty).as_("stock_qty"),
)
.where(
(purchase_order_item.sales_order_item.isin(sales_order_items))
& (purchase_order_item.docstatus == 1)
)
.groupby(purchase_order_item.sales_order_item)
.run(as_dict=True)
)
for row in buying_amounts:
self.drop_ship_buying_rates[row.sales_order_item] = (
flt(row.buying_amount) / flt(row.stock_qty) if flt(row.stock_qty) else 0
)
def get_drop_ship_buying_amount(self, row):
if row.so_detail not in self.drop_ship_buying_rates:
return
return flt(row.qty) * self.drop_ship_buying_rates[row.so_detail]
def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code):
from frappe.query_builder.functions import Avg

View File

@@ -4,7 +4,7 @@ from frappe.utils import add_days, flt, get_first_day, get_last_day, nowdate
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note, make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.gross_profit.gross_profit import execute
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator, execute
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import create_item
@@ -676,19 +676,9 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertEqual(total[8], 0.0) # gross profit %
def test_drop_ship(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
so = make_sales_order(item=item.name, qty=10, rate=100)
po = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
po.items[0].rate = 80
po.supplier = "_Test Supplier"
po.submit()
make_purchase_invoice(po.name).submit()
so = self.create_drop_ship_order()
si = make_sales_invoice(so.name).submit()
filters = frappe._dict(
@@ -700,6 +690,357 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertIsNone(data[1].buying_rate)
self.assertEqual(data[1]["gross_profit_%"], 20)
def test_drop_ship_partial_billing_and_return(self):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
so = self.create_drop_ship_order()
first_invoice = make_sales_invoice(so.name)
first_invoice.items[0].qty = 4
first_invoice.submit()
second_invoice = make_sales_invoice(so.name).submit()
filters = frappe._dict(
company=first_invoice.company,
from_date=first_invoice.posting_date,
to_date=first_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_rows = {
row.parent_invoice: row
for row in data
if row.parent_invoice in {first_invoice.name, second_invoice.name} and row.indent == 1
}
self.assertEqual(invoice_rows[first_invoice.name].buying_amount, 320)
self.assertEqual(invoice_rows[second_invoice.name].buying_amount, 480)
sales_return = make_sales_return(first_invoice.name)
sales_return.items[0].qty = -2
sales_return.submit()
_, data = execute(filters=filters)
first_invoice_row = next(
row for row in data if row.parent_invoice == first_invoice.name and row.indent == 1
)
self.assertEqual(first_invoice_row.qty, 2)
self.assertEqual(first_invoice_row.buying_amount, 160)
self.assertEqual(first_invoice_row.gross_profit, 40)
def test_drop_ship_return_matches_sales_invoice_item(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(
"_Test Drop Ship Consolidated Return Item",
properties={"is_stock_item": 1, "delivered_by_supplier": 1},
)
sales_orders = []
for qty, selling_rate, buying_rate in [(4, 100, 50), (6, 200, 80)]:
sales_order = make_sales_order(item=item.name, qty=qty, rate=selling_rate, do_not_submit=True)
sales_order.items[0].delivered_by_supplier = 1
sales_order.items[0].supplier = "_Test Supplier"
sales_order.submit()
sales_orders.append(sales_order)
purchase_order = make_purchase_order(sales_order.name, selected_items=[sales_order.items[0]])[0]
purchase_order.items[0].rate = buying_rate
purchase_order.supplier = "_Test Supplier"
purchase_order.submit()
make_purchase_invoice(purchase_order.name).submit()
sales_invoice = make_sales_invoice(sales_orders[0].name)
sales_invoice = make_sales_invoice(sales_orders[1].name, target_doc=sales_invoice).submit()
sales_return = make_sales_return(sales_invoice.name)
sales_return.set("items", [sales_return.items[0]])
sales_return.items[0].qty = -1
sales_return.submit()
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
self.assertEqual([row.qty for row in invoice_rows], [3, 6])
self.assertEqual([row.buying_amount for row in invoice_rows], [150, 480])
def test_return_matches_sales_invoice_item_for_delivery_note(self):
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=4,
basic_rate=50,
)
delivery_note = self.create_delivery_note(qty=4, rate=100)
sales_invoice = make_sales_invoice(delivery_note.name).submit()
sales_return = make_sales_return(sales_invoice.name)
sales_return.items[0].qty = -1
sales_return.submit()
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_row = next(
row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
)
self.assertEqual(invoice_row.qty, 3)
self.assertEqual(invoice_row.selling_amount, 300)
def test_return_combines_linked_and_legacy_item_buckets(self):
sales_invoice = self.create_sales_invoice(qty=4, rate=100)
linked_return = make_sales_return(sales_invoice.name)
linked_return.items[0].qty = -1
linked_return.submit()
legacy_return = make_sales_return(sales_invoice.name)
legacy_return.items[0].qty = -1
legacy_return.submit()
frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_row = next(
row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
)
self.assertEqual(invoice_row.qty, 2)
self.assertEqual(invoice_row.selling_amount, 200)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
def test_legacy_return_prefers_item_without_linked_return(self):
sales_invoice = self.create_sales_invoice(qty=2, rate=100, do_not_submit=True)
second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
second_item.rate = 200
sales_invoice.append("items", second_item)
sales_invoice.submit()
linked_return = make_sales_return(sales_invoice.name)
linked_return.set("items", [linked_return.items[0]])
linked_return.items[0].qty = -1
linked_return.submit()
legacy_return = make_sales_return(sales_invoice.name)
legacy_return.set("items", [legacy_return.items[1]])
legacy_return.items[0].qty = -1
legacy_return.submit()
frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
self.assertEqual([row.qty for row in invoice_rows], [1, 1])
self.assertEqual([row.selling_amount for row in invoice_rows], [100, 200])
def test_legacy_return_remainder_spills_into_linked_item(self):
invoice = "SINV-TEST-RETURN-ALLOCATION"
linked_item = "SINV-ITEM-LINKED"
unlinked_item = "SINV-ITEM-LEGACY"
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
generator.currency_precision = 3
generator.filters = frappe._dict(group_by="Invoice")
generator.returned_invoices = frappe._dict(
{invoice: frappe._dict({linked_item: [frappe._dict(qty=-1, base_amount=-100)]})}
)
generator.legacy_returned_invoices = frappe._dict(
{invoice: frappe._dict({self.item: [frappe._dict(qty=-2, base_amount=-200)]})}
)
linked_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=linked_item,
is_return=False,
qty=3,
base_amount=300,
buying_rate=50,
delivered_by_supplier=False,
)
unlinked_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=unlinked_item,
is_return=False,
qty=1,
base_amount=100,
buying_rate=50,
delivered_by_supplier=False,
)
generator.si_list = [unlinked_row, linked_row]
generator.allocate_legacy_return_items()
generator.update_return_invoices(linked_row, linked_item)
generator.update_return_invoices(unlinked_row, unlinked_item)
self.assertEqual((linked_row.qty, linked_row.base_amount), (1, 100))
self.assertEqual((unlinked_row.qty, unlinked_row.base_amount), (0, 0))
def test_legacy_return_ignores_skipped_group_rows(self):
invoice = "SINV-TEST-SKIPPED-RETURN-ALLOCATION"
visible_item = "SINV-ITEM-WITH-PROJECT"
skipped_item = "SINV-ITEM-WITHOUT-PROJECT"
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
generator.currency_precision = 3
generator.filters = frappe._dict(group_by="Project")
generator.returned_invoices = frappe._dict(
{invoice: frappe._dict({visible_item: [frappe._dict(qty=-1, base_amount=-100)]})}
)
generator.legacy_returned_invoices = frappe._dict(
{invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
)
visible_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=visible_item,
is_return=False,
project="_Test Project",
qty=2,
base_amount=200,
buying_rate=50,
delivered_by_supplier=False,
)
skipped_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=skipped_item,
is_return=False,
project=None,
qty=1,
)
generator.si_list = [visible_row, skipped_row]
generator.allocate_legacy_return_items()
generator.update_return_invoices(visible_row, visible_item)
self.assertNotIn(skipped_item, generator.returned_invoices[invoice])
self.assertEqual((visible_row.qty, visible_row.base_amount), (0, 0))
def test_monthly_group_allocates_legacy_return(self):
invoice = "SINV-TEST-MONTHLY-RETURN-ALLOCATION"
item_row = "SINV-ITEM-MONTHLY-RETURN"
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
generator.currency_precision = 3
generator.filters = frappe._dict(group_by="Monthly")
generator.returned_invoices = frappe._dict()
generator.legacy_returned_invoices = frappe._dict(
{invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
)
invoice_row = frappe._dict(
parent=invoice,
item_code=self.item,
item_row=item_row,
is_return=False,
posting_date=nowdate(),
qty=1,
base_amount=100,
buying_rate=50,
delivered_by_supplier=False,
)
generator.si_list = [invoice_row]
generator.allocate_legacy_return_items()
generator.update_return_invoices(invoice_row, item_row)
self.assertEqual((invoice_row.qty, invoice_row.base_amount), (0, 0))
def test_return_remainder_stays_available_for_next_row(self):
invoice = "SINV-TEST-RETURN-REMAINDER"
item_row = "SINV-ITEM-RETURN-REMAINDER"
returned_item = frappe._dict(qty=-2, base_amount=-200)
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
generator.currency_precision = 3
generator.returned_invoices = frappe._dict({invoice: frappe._dict({item_row: [returned_item]})})
first_row = frappe._dict(
parent=invoice,
item_code=self.item,
qty=1,
base_amount=100,
buying_rate=50,
delivered_by_supplier=False,
)
second_row = first_row.copy()
generator.update_return_invoices(first_row, item_row)
self.assertEqual((returned_item.qty, returned_item.base_amount), (-1, -100))
generator.update_return_invoices(second_row, item_row)
self.assertEqual((returned_item.qty, returned_item.base_amount), (0, 0))
self.assertEqual((first_row.qty, second_row.qty), (0, 0))
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
def test_return_keeps_buying_amount_of_unreturned_row(self):
unreturned_item = create_item(
"_Test Gross Profit Unreturned Item", warehouse=self.warehouse, company=self.company
)
make_stock_entry(
company=self.company,
item_code=unreturned_item.name,
target=self.warehouse,
qty=40000,
basic_rate=33.33333,
)
sales_invoice = self.create_sales_invoice(qty=1, rate=100, do_not_submit=True)
second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
second_item.item_code = unreturned_item.name
second_item.item_name = unreturned_item.name
second_item.qty = 30000
sales_invoice.append("items", second_item)
sales_invoice.submit()
sales_return = make_sales_return(sales_invoice.name)
sales_return.set("items", [sales_return.items[0]])
sales_return.items[0].qty = -1
sales_return.submit()
filters = frappe._dict(
company=sales_invoice.company,
from_date=sales_invoice.posting_date,
to_date=sales_invoice.posting_date,
group_by="Invoice",
)
_, data = execute(filters=filters)
invoice_row = next(
row
for row in data
if row.parent_invoice == sales_invoice.name and row.item_code == unreturned_item.name
)
self.assertEqual(invoice_row.qty, 30000)
self.assertEqual(invoice_row.buying_amount, 999999.9)
def create_drop_ship_order(self, qty=10, selling_rate=100, buying_rate=80):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
so = make_sales_order(item=item.name, qty=qty, rate=selling_rate)
purchase_order = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
purchase_order.items[0].rate = buying_rate
purchase_order.supplier = "_Test Supplier"
purchase_order.submit()
make_purchase_invoice(purchase_order.name).submit()
return so
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
"""Create a rate adjustment debit note with no stock movement."""
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)

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

@@ -304,6 +304,9 @@ def get_payment_entries(filters, args):
pe.mode_of_payment,
pe.project,
pe.cost_center,
pe.payment_type,
pe.source_exchange_rate,
pe.target_exchange_rate,
)
.where(
(pe.docstatus == 1)
@@ -314,6 +317,22 @@ def get_payment_entries(filters, args):
)
query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True)
payment_entries = query.run(as_dict=True)
if payment_entries:
ded = frappe.qb.DocType("Payment Entry Deduction")
deduction_totals = frappe._dict(
frappe.qb.from_(ded)
.select(ded.parent, Sum(ded.amount))
.where(ded.parent.isin([d.name for d in payment_entries]) & (ded.is_exchange_gain_loss == 0))
.groupby(ded.parent)
.run()
)
for d in payment_entries:
exchange_rate = (
d.source_exchange_rate if d.payment_type == "Receive" else d.target_exchange_rate
) or 1
d.base_grand_total = flt(d.base_grand_total) + flt(deduction_totals.get(d.name)) / exchange_rate
return payment_entries

View File

@@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.party import get_party_shipping_address
from erpnext.accounts.utils import (
get_currency_precision,
get_future_stock_vouchers,
get_voucherwise_gl_entries,
get_zero_cutoff,
@@ -155,3 +156,18 @@ class TestUtils(ERPNextTestSuite):
self.assertEqual(get_zero_cutoff(None), 0.005)
self.assertEqual(get_zero_cutoff("EUR"), 0.005)
self.assertEqual(get_zero_cutoff("BHD"), 0.0005)
def test_get_currency_precision_respects_zero_and_fallback(self):
currency_precision = frappe.db.get_default("currency_precision")
number_format = frappe.db.get_default("number_format")
try:
frappe.db.set_default("number_format", "#,###.##")
frappe.db.set_default("currency_precision", "0")
self.assertEqual(get_currency_precision(), 0)
frappe.db.set_default("currency_precision", "")
self.assertEqual(get_currency_precision(), 2)
finally:
frappe.db.set_default("currency_precision", currency_precision or "")
frappe.db.set_default("number_format", number_format or "#,###.##")

View File

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

View File

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

View File

@@ -400,6 +400,33 @@ class TestAssetCapitalization(ERPNextTestSuite):
actual_gle = get_actual_gle_dict(asset_capitalization.name)
self.assertEqual(actual_gle, {})
def test_grouped_stock_item_rows_split_fifo_rate(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company"
warehouse = create_warehouse("_Test Warehouse for Grouped FIFO Rows", company=company)
item = create_item(
"_Test Grouped FIFO Rows Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=1
)
target_item = create_fixed_asset_item("_Test Grouped FIFO Rows Target Item")
make_purchase_receipt(item_code=item.item_code, qty=1, rate=100, company=company, warehouse=warehouse)
make_purchase_receipt(item_code=item.item_code, qty=1, rate=200, company=company, warehouse=warehouse)
asset_capitalization = frappe.new_doc("Asset Capitalization")
asset_capitalization.company = company
asset_capitalization.target_item_code = target_item.name
asset_capitalization.append(
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
)
asset_capitalization.append(
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
)
asset_capitalization.insert()
rates = [d.valuation_rate for d in asset_capitalization.stock_items]
self.assertEqual(rates, [100, 200])
def create_asset_capitalization_data():
create_item("Capitalization Target Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0)

View File

@@ -388,6 +388,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",
@@ -1323,7 +1324,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:34:19.659621",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -18,6 +18,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
from erpnext.accounts.party import get_party_account, get_party_account_currency
from erpnext.buying.utils import 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,
)
@@ -769,13 +770,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", [])
@@ -807,7 +811,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),
@@ -866,9 +872,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)
@@ -911,6 +921,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

@@ -559,6 +559,7 @@
"fieldname": "blanket_order_rate",
"fieldtype": "Currency",
"label": "Blanket Order Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
@@ -954,7 +955,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-08-27 10:55:37.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

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

View File

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

View File

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

View File

@@ -363,7 +363,7 @@
},
{
"default": "0",
"description": "Frozen suppliers block ledger entries until unfrozen. Use this to temporarily lock accounting activity without disabling the supplier.",
"description": "Frozen suppliers block new transactions and ledger entries until unfrozen. Only users with the role set in Company's \"Roles Allowed to Set and Edit Frozen Account Entries\" can transact.",
"fieldname": "is_frozen",
"fieldtype": "Check",
"label": "Is Frozen"
@@ -562,7 +562,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-06-27 16:12:33.190257",
"modified": "2026-08-14 16:10:58.600553",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",

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,
@@ -181,10 +182,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",
@@ -947,7 +948,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:29:37.509487",
"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"}
@@ -246,6 +247,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")
@@ -280,7 +283,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,
},
{
fieldname: "show_aggregate_value_from_subsidiary_companies",
label: __("Show Aggregate Value from Subsidiary Companies"),

View File

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

View File

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

View File

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

View File

@@ -738,6 +738,8 @@ class AccountsController(TransactionBase):
self.validate_non_invoice_documents_schedule()
def before_print(self, settings=None):
self.set_missing_terms()
if self.doctype in [
"Purchase Order",
"Sales Order",
@@ -761,6 +763,16 @@ class AccountsController(TransactionBase):
set_print_templates_for_item_table(self, settings)
set_print_templates_for_taxes(self, settings)
def set_missing_terms(self):
if not self.get("tc_name") or self.get("terms"):
return
from erpnext.setup.doctype.terms_and_conditions.terms_and_conditions import (
get_terms_and_conditions,
)
self.terms = get_terms_and_conditions(self.tc_name, self.as_dict())
def calculate_paid_amount(self):
if hasattr(self, "is_pos") or hasattr(self, "is_paid"):
is_paid = self.get("is_pos") or self.get("is_paid")
@@ -1293,6 +1305,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"):
@@ -1409,9 +1426,12 @@ class AccountsController(TransactionBase):
dimension_dict = frappe._dict()
for dimension in accounting_dimensions:
dimension_dict[dimension] = self.get(dimension)
value = self.get(dimension)
if item and item.get(dimension):
dimension_dict[dimension] = item.get(dimension)
value = item.get(dimension)
if isinstance(value, list | dict):
continue
dimension_dict[dimension] = value
gl_dict.update(dimension_dict)
gl_dict.update(args)

View File

@@ -85,18 +85,10 @@ class BuyingController(SubcontractingController):
),
)
if (
self.get("company")
and (
default_buying_terms := frappe.get_value(
"Company", self.get("company"), "default_buying_terms"
)
)
and not self.get("tc_name")
and not self.get("terms")
):
self.tc_name = default_buying_terms
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
if self.get("company") and not self.get("terms"):
if not self.get("tc_name"):
self.tc_name = frappe.get_value("Company", self.company, "default_buying_terms")
self.set_missing_terms()
def validate_posting_date_with_po(self):
po_list = {x.purchase_order for x in self.items if x.purchase_order}
@@ -981,6 +973,14 @@ class BuyingController(SubcontractingController):
item.serial_and_batch_bundle, warehouse, type_of_transaction=type_of_transaction
)
def check_purchase_order_on_hold_or_close(self, ref_fieldname, exclude_if_field=None):
if self.get("is_return"):
return
self.check_for_on_hold_or_closed_status(
"Purchase Order", ref_fieldname, exclude_if_field=exclude_if_field
)
def update_ordered_and_reserved_qty(self):
po_map = {}
for d in self.get("items"):
@@ -994,7 +994,7 @@ class BuyingController(SubcontractingController):
if po and po_item_rows:
po_obj = frappe.get_lazy_doc("Purchase Order", po)
if po_obj.status in ["Closed", "Cancelled"]:
if po_obj.status == "Cancelled" or (po_obj.status == "Closed" and not self.get("is_return")):
frappe.throw(
_("{doctype} {name} is cancelled or closed.").format(
doctype=frappe.bold(_("Purchase Order")),

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

@@ -639,7 +639,7 @@ def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
bo = frappe.qb.DocType("Blanket Order")
bo_item = frappe.qb.DocType("Blanket Order Item")
blanket_orders = (
query = (
frappe.qb.from_(bo)
.from_(bo_item)
.select(bo.name)
@@ -652,10 +652,12 @@ def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
& (bo.company == filters.get("company"))
& (bo.docstatus == 1)
)
.run()
)
return blanket_orders
if currency := filters.get("currency"):
query = query.where(bo.currency == currency)
return query.run()
@frappe.whitelist()

View File

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

View File

@@ -43,18 +43,10 @@ class SellingController(StockController):
),
)
if (
self.get("company")
and (
default_selling_terms := frappe.get_value(
"Company", self.get("company"), "default_selling_terms"
)
)
and not self.get("tc_name")
and not self.get("terms")
):
self.tc_name = default_selling_terms
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
if self.get("company") and not self.get("terms"):
if not self.get("tc_name"):
self.tc_name = frappe.get_value("Company", self.company, "default_selling_terms")
self.set_missing_terms()
def validate(self):
super().validate()
@@ -573,12 +565,12 @@ class SellingController(StockController):
reset_incoming_rate()
if (
not d.incoming_rate
(not d.incoming_rate or self.is_new())
and not is_standalone
or self.is_internal_transfer()
or (
get_valuation_method(d.item_code, self.company) == "Moving Average"
and self.get("is_return")
and not is_standalone
)
):
d.incoming_rate = get_incoming_rate(

View File

@@ -75,7 +75,7 @@ SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
def is_inspection_exempt_secondary_row(doc, row) -> bool:
"""Whether the row is a secondary item on a document that produces secondary items."""
if not (row.get("type") or row.get("is_legacy_scrap_item")):
if not (row.get("secondary_item_type") or row.get("valuation_type")):
return False
if doc.doctype == "Stock Entry":
@@ -86,7 +86,7 @@ def is_inspection_exempt_secondary_row(doc, row) -> bool:
def stock_entry_row_requires_inspection(purpose, row):
"""Check if this Stock Entry row need a Quality Inspection."""
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("type") or row.get("is_legacy_scrap_item")):
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("secondary_item_type") or row.get("valuation_type")):
return False
if purpose == "Manufacture":
return bool(row.is_finished_item)

View File

@@ -161,7 +161,7 @@ class SubcontractingController(StockController):
).format(item.idx, get_link_to_form("Item", item.item_code))
)
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
if not is_sub_contracted_item:
frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
@@ -1288,10 +1288,10 @@ class SubcontractingController(StockController):
total_amt = sum(
flt(item.amount)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
if not item.get("secondary_item_type") and not item.get("valuation_type")
)
for item in self.items:
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt
) / item.qty
@@ -1299,15 +1299,15 @@ class SubcontractingController(StockController):
total_qty = sum(
flt(item.qty)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
if not item.get("secondary_item_type") and not item.get("valuation_type")
)
additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items:
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = additional_cost_per_qty
else:
for item in self.items:
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = 0
@frappe.whitelist()

View File

@@ -241,8 +241,8 @@ class SubcontractingInwardController:
item
for item in self.get("items")
if not item.is_finished_item
and not item.type
and not item.is_legacy_scrap_item
and not item.secondary_item_type
and not item.valuation_type
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
]
@@ -372,7 +372,7 @@ class SubcontractingInwardController:
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
for item in self.items:
if (
item.is_finished_item or item.type or item.is_legacy_scrap_item
item.is_finished_item or item.secondary_item_type or item.valuation_type
) and item.valuation_rate == 0:
item.allow_zero_valuation_rate = 1
@@ -472,7 +472,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save()
else:
for item in self.items:
if not item.type and not item.is_legacy_scrap_item:
if not item.secondary_item_type and not item.valuation_type:
delivered_qty, returned_qty = frappe.get_value(
"Subcontracting Inward Order Item",
item.scio_detail,
@@ -543,7 +543,7 @@ class SubcontractingInwardController:
bold(
frappe.get_cached_value(
"Subcontracting Inward Order Item"
if not item.type and not item.is_legacy_scrap_item
if not item.secondary_item_type and not item.valuation_type
else "Subcontracting Inward Order Secondary Item",
item.scio_detail,
"stock_uom",
@@ -595,7 +595,7 @@ class SubcontractingInwardController:
)
for item in [item for item in self.items if not item.is_finished_item]:
if item.type or item.is_legacy_scrap_item:
if item.secondary_item_type or item.valuation_type:
scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Secondary Item",
{
@@ -655,7 +655,7 @@ class SubcontractingInwardController:
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.type and not item.is_legacy_scrap_item
if not item.secondary_item_type and not item.valuation_type
else "Subcontracting Inward Order Secondary Item"
)
qty_map[doctype][item.scio_detail] += (
@@ -791,7 +791,7 @@ class SubcontractingInwardController:
items = [
item
for item in self.items
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item
if not item.is_finished_item and not item.secondary_item_type and not item.valuation_type
]
item_code_wh = frappe._dict(
{
@@ -893,7 +893,9 @@ class SubcontractingInwardController:
def update_inward_order_secondary_items(self):
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item]
secondary_items_list = [
item for item in self.items if item.secondary_item_type or item.valuation_type
]
secondary_items = defaultdict(float)
for item in secondary_items_list:
@@ -967,7 +969,7 @@ class SubcontractingInwardController:
stock_uom=secondary_item.stock_uom,
warehouse=secondary_item.t_warehouse,
produced_qty=secondary_item.transfer_qty,
type=secondary_item.type,
secondary_item_type=secondary_item.secondary_item_type,
delivered_qty=0,
reference_name=frappe.get_value(
"Work Order", self.work_order, "subcontracting_inward_order_item"

View File

@@ -314,33 +314,32 @@ class calculate_taxes_and_totals:
for item in self.doc.items:
item._unrounded_net_amount = None
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
cumulated_tax_fraction = 0
total_inclusive_tax_amount_per_qty = 0
total_tax_slope = 0
total_tax_intercept = 0
for i, tax in enumerate(self.doc.get("taxes")):
(
tax.tax_fraction_for_current_item,
inclusive_tax_amount_per_qty,
) = self.get_current_tax_fraction(tax, item_tax_map)
tax_intercept_per_qty,
) = self.get_current_tax_fraction(tax, item_tax_map, item)
tax.inclusive_amount_per_qty = tax_intercept_per_qty
if i == 0:
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item
tax.grand_total_amount_per_qty = tax_intercept_per_qty
else:
prev = self.doc.get("taxes")[i - 1]
tax.grand_total_fraction_for_current_item = (
self.doc.get("taxes")[i - 1].grand_total_fraction_for_current_item
+ tax.tax_fraction_for_current_item
prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item
)
tax.grand_total_amount_per_qty = prev.grand_total_amount_per_qty + tax_intercept_per_qty
cumulated_tax_fraction += tax.tax_fraction_for_current_item
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty)
total_tax_slope += tax.tax_fraction_for_current_item
total_tax_intercept += tax_intercept_per_qty * flt(item.qty)
if (
not self.discount_amount_applied
and item.qty
and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty)
):
amount = flt(item.amount) - total_inclusive_tax_amount_per_qty
if not self.discount_amount_applied and item.qty and (total_tax_slope or total_tax_intercept):
amount = flt(item.amount) - total_tax_intercept
item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction)
item._unrounded_net_amount = amount / (1 + total_tax_slope)
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
item.discount_percentage = flt(
@@ -352,41 +351,48 @@ class calculate_taxes_and_totals:
def _load_item_tax_rate(self, item_tax_rate):
return json.loads(item_tax_rate) if item_tax_rate else {}
def get_current_tax_fraction(self, tax, item_tax_map):
def get_current_tax_fraction(self, tax, item_tax_map, item):
"""
Get tax fraction for calculating tax exclusive amount
from tax inclusive amount
tax = slope * net + intercept.
Returns (slope, intercept_per_qty)
"""
current_tax_fraction = 0
inclusive_tax_amount_per_qty = 0
tax_slope = 0
tax_intercept = 0
if cint(tax.included_in_print_rate):
tax_rate = self._get_tax_rate(tax, item_tax_map)
if tax_rate == NOT_APPLICABLE_TAX:
return current_tax_fraction, inclusive_tax_amount_per_qty
return tax_slope, tax_intercept
if tax.charge_type == "On Net Total":
current_tax_fraction = tax_rate / 100.0
tax_slope = tax_rate / 100.0
elif tax.charge_type == "On Previous Row Amount":
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
cint(tax.row_id) - 1
].tax_fraction_for_current_item
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "inclusive_amount_per_qty", 0))
elif tax.charge_type == "On Previous Row Total":
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
cint(tax.row_id) - 1
].grand_total_fraction_for_current_item
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "grand_total_amount_per_qty", 0))
elif tax.charge_type == "On Item Quantity":
inclusive_tax_amount_per_qty = flt(tax_rate)
tax_intercept = flt(tax_rate)
else:
# Custom charge_type: the rate applies to a resolved (fixed) base,
# e.g. a tax on MRP included in the printed price.
qty = flt(item.qty) or 1
base = self.get_item_taxable_base(item, tax)
tax_intercept = (tax_rate / 100.0) * base / qty
if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct":
current_tax_fraction *= -1.0
inclusive_tax_amount_per_qty *= -1.0
tax_slope *= -1.0
tax_intercept *= -1.0
return current_tax_fraction, inclusive_tax_amount_per_qty
return tax_slope, tax_intercept
def _get_tax_rate(self, tax, item_tax_map):
if tax.account_head in item_tax_map:
@@ -612,7 +618,6 @@ class calculate_taxes_and_totals:
elif tax.charge_type == "On Net Total":
if tax.account_head in item_tax_map:
current_net_amount = item.net_amount
# Use unrounded net for inclusive taxes to avoid double rounding
if (
cint(tax.included_in_print_rate)
@@ -631,12 +636,46 @@ class calculate_taxes_and_totals:
elif tax.charge_type == "On Item Quantity":
# don't sum current net amount due to the field being a currency field
current_tax_amount = tax_rate * item.qty
else:
# Custom charge_type: rate applies to the resolver-provided base.
base = self.get_item_taxable_base(item, tax)
current_net_amount = base
current_tax_amount = (tax_rate / 100.0) * base
if not tax.get("dont_recompute_tax"):
self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount, current_net_amount)
return current_net_amount, current_tax_amount
def get_item_taxable_base(self, item, tax):
"""Per-item base a custom charge_type's rate is applied to.
Override the base (gross, MRP, net of other taxes, …) via the
`erpnext_taxable_base_resolvers` hook
Register a resolver in `hooks.py`, keyed by charge_type:
erpnext_taxable_base_resolvers = {"On Gross Amount": "my_app.taxes.gross_base"}
It receives (calc, item, tax) — calc is this instance, calc.doc the parent —
and returns the base (flt-coerced by the caller):
def gross_base(calc, item, tax):
return item.custom_field_mrp * item.qty
A resolver may stamp transient attributes on `item`; it can be called more than once
per item, so such stamping must be idempotent.
"""
resolvers = frappe.get_hooks("erpnext_taxable_base_resolvers") or {}
path = resolvers.get(tax.charge_type)
if path:
method = path[-1] if isinstance(path, list | tuple) else path
return flt(frappe.get_attr(method)(self, item, tax))
# fallback
return flt(item.net_amount)
def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount):
# store tax breakup for each item
multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1

View File

@@ -31,6 +31,97 @@ class TestMapper(ERPNextTestSuite):
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.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_for_items,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
self.load_test_records("Supplier Quotation")
def make_supplier_quotation():
return frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][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_for_items(["_Test Item"]),
"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

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

View File

@@ -1,12 +1,24 @@
from unittest import mock
from unittest.mock import patch
import frappe
from frappe.utils import flt
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
def resolve_on_gross(calc, item, tax):
# base = gross printed line amount
return flt(item.amount)
def resolve_on_mrp(calc, item, tax):
# base = MRP, not net
return flt(item.price_list_rate) * flt(item.qty)
class TestTaxesAndTotals(ERPNextTestSuite):
def test_regional_round_off_accounts(self):
"""
@@ -30,6 +42,93 @@ class TestTaxesAndTotals(ERPNextTestSuite):
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
def test_exclusive_custom_charge_on_resolved_base(self):
"""Added (exclusive) custom charge_type whose base is resolved by the
`erpnext_taxable_base_resolvers` hook. IPI 10% on the gross product value 1000
-> tax 100, net 1000, grand 1100."""
so = make_sales_order(do_not_save=True)
so.items = []
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 1000,
"price_list_rate": 1000,
"warehouse": "_Test Warehouse - _TC",
},
)
so.set("taxes", [])
so.append(
"taxes",
{
"charge_type": "On Gross Value",
"account_head": "_Test Account Excise Duty - _TC",
"description": "IPI 10% on gross product value",
"rate": 10,
"cost_center": "_Test Cost Center - _TC",
},
)
real_get_hooks = frappe.get_hooks
def fake_get_hooks(hook=None, *args, **kwargs):
if hook == "erpnext_taxable_base_resolvers":
return {
"On Gross Value": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_gross"]
}
return real_get_hooks(hook, *args, **kwargs)
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
calculate_taxes_and_totals(so)
self.assertEqual(so.net_total, 1000.0)
self.assertEqual(so.taxes[0].tax_amount, 100.0)
self.assertEqual(so.grand_total, 1100.0)
def test_inclusive_custom_charge_on_resolved_base(self):
"""Inclusive custom charge on a resolved base backs out non-compounding
(tax = rate x resolved base) — a resolved base is fixed, so it never
compounds. MRP 1200, printed 1000, rate 10%: tax 120, net 880."""
so = make_sales_order(do_not_save=True)
so.items = []
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 1000,
"price_list_rate": 1200,
"warehouse": "_Test Warehouse - _TC",
},
)
so.set("taxes", [])
so.append(
"taxes",
{
"charge_type": "On MRP",
"account_head": "_Test Account VAT - _TC",
"description": "Tax 10% on MRP, inclusive",
"rate": 10,
"included_in_print_rate": 1,
"cost_center": "_Test Cost Center - _TC",
},
)
real_get_hooks = frappe.get_hooks
def fake_get_hooks(hook=None, *args, **kwargs):
if hook == "erpnext_taxable_base_resolvers":
return {"On MRP": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_mrp"]}
return real_get_hooks(hook, *args, **kwargs)
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
calculate_taxes_and_totals(so)
self.assertEqual(so.taxes[0].tax_amount, 120.0)
self.assertEqual(so.net_total, 880.0)
self.assertEqual(so.grand_total, 1000.0)
def test_disabling_rounded_total_resets_base_fields(self):
"""Disabling rounded total should also clear base rounded values."""
so = make_sales_order(do_not_save=True)

View File

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

View File

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

View File

@@ -29,12 +29,19 @@ class EmailCampaign(Document):
def validate(self):
self.set_date()
# checking if email is set for lead. Not checking for contact as email is a mandatory field for contact.
if self.email_campaign_for == "Lead":
self.validate_lead()
self.validate_recipient_email()
self.validate_email_campaign_already_exists()
self.update_status()
def validate_recipient_email(self):
if not self.recipient:
return
if self.email_campaign_for == "Lead":
self.validate_lead()
elif self.email_campaign_for == "Contact":
self.validate_contact()
def set_date(self):
if getdate(self.start_date) < getdate(today()):
frappe.throw(_("Start Date cannot be before the current date"))
@@ -56,6 +63,13 @@ class EmailCampaign(Document):
lead_name = frappe.db.get_value("Lead", self.recipient, "lead_name")
frappe.throw(_("Please set an email id for the Lead {0}").format(lead_name))
def validate_contact(self):
contact = frappe.db.get_value("Contact", self.recipient, ["email_id", "full_name"], as_dict=True)
if contact and not contact.email_id:
frappe.throw(
_("Please set a primary email ID for the Contact {0}").format(frappe.bold(contact.full_name))
)
def validate_email_campaign_already_exists(self):
email_campaign_exists = frappe.db.exists(
"Email Campaign",

View File

@@ -1,10 +1,84 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import unittest
import frappe
from frappe.utils import add_days, getdate, today
from erpnext.tests.utils import ERPNextTestSuite
class TestEmailCampaign(ERPNextTestSuite):
pass
"""Email Campaign derives its window from the linked Campaign schedule and
guards the start date and the recipient's email."""
def setUp(self):
frappe.set_user("Administrator")
def make_email_template(self):
name = "_Test EC Email Template"
if not frappe.db.exists("Email Template", name):
frappe.get_doc(
{"doctype": "Email Template", "name": name, "subject": "Test", "response": "Hello"}
).insert()
return name
def make_campaign(self, schedules):
campaign = frappe.new_doc("Campaign")
campaign.campaign_name = f"_Test EC Campaign {frappe.generate_hash(length=6)}"
for days in schedules:
campaign.append(
"campaign_schedules",
{"send_after_days": days, "email_template": self.make_email_template()},
)
return campaign.insert()
def make_email_campaign(self, campaign_name, start_date=None):
doc = frappe.new_doc("Email Campaign")
doc.campaign_name = campaign_name
doc.start_date = start_date or today()
return doc
def test_start_date_cannot_be_in_the_past(self):
doc = self.make_email_campaign("irrelevant", start_date=add_days(today(), -1))
self.assertRaises(frappe.ValidationError, doc.set_date)
def test_end_date_is_start_plus_max_send_after_days(self):
campaign = self.make_campaign(schedules=[0, 5])
doc = self.make_email_campaign(campaign.name)
doc.set_date()
self.assertEqual(getdate(doc.end_date), add_days(getdate(today()), 5))
def test_campaign_without_a_schedule_is_rejected(self):
campaign = self.make_campaign(schedules=[])
doc = self.make_email_campaign(campaign.name)
self.assertRaises(frappe.ValidationError, doc.set_date)
def test_lead_without_an_email_is_rejected(self):
lead = frappe.get_doc({"doctype": "Lead", "lead_name": "_Test Lead No Email"}).insert()
doc = frappe.new_doc("Email Campaign")
doc.email_campaign_for = "Lead"
doc.recipient = lead.name
self.assertRaises(frappe.ValidationError, doc.validate_lead)
def test_contact_without_an_email_is_rejected(self):
contact = frappe.get_doc({"doctype": "Contact", "first_name": "_Test Contact No Email"}).insert()
campaign = self.make_campaign(schedules=[0])
doc = self.make_email_campaign(campaign.name)
doc.email_campaign_for = "Contact"
doc.recipient = contact.name
self.assertRaisesRegex(frappe.ValidationError, "primary email ID", doc.insert)
def test_contact_with_an_email_is_accepted(self):
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "_Test Contact With Email",
"email_ids": [{"email_id": "_test_email_campaign@example.com", "is_primary": 1}],
}
).insert()
campaign = self.make_campaign(schedules=[0])
doc = self.make_email_campaign(campaign.name)
doc.email_campaign_for = "Contact"
doc.recipient = contact.name
doc.insert()
self.assertEqual(doc.status, "In Progress")

View File

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

View File

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

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