Compare commits

...

174 Commits

Author SHA1 Message Date
Frappe PR Bot
4dd9f0b255 chore(release): Bumped to Version 15.106.0
# [15.106.0](https://github.com/frappe/erpnext/compare/v15.105.0...v15.106.0) (2026-04-28)

### Bug Fixes

* **`get_stock_balance`:** validate inventory dimension fieldnames (backport [#54587](https://github.com/frappe/erpnext/issues/54587)) ([#54588](https://github.com/frappe/erpnext/issues/54588)) ([03f3a28](03f3a28f54))
* **accounts:** fetch project name from payment entry to journal entry ([55cce2a](55cce2a11c))
* add party_type for dynamic link and add it to grouping key ([a3ad1fb](a3ad1fb163))
* add project filter to accounts payable and receivable reports (backport [#54344](https://github.com/frappe/erpnext/issues/54344)) ([#54441](https://github.com/frappe/erpnext/issues/54441)) ([44f3f34](44f3f34c9e))
* avoid double reduction of pe reference outstanding (backport [#54193](https://github.com/frappe/erpnext/issues/54193)) ([#54612](https://github.com/frappe/erpnext/issues/54612)) ([51e7c66](51e7c66043))
* debit credit not equal in purchase transactions for multi currency (backport [#54456](https://github.com/frappe/erpnext/issues/54456)) ([#54563](https://github.com/frappe/erpnext/issues/54563)) ([78b2e45](78b2e45cb9))
* duplicate entries being shown in batch exists in future transact… (backport [#54604](https://github.com/frappe/erpnext/issues/54604)) ([#54605](https://github.com/frappe/erpnext/issues/54605)) ([176d980](176d980764))
* **edi:** restrict Code List imports to files and trusted backend URLs (backport [#54137](https://github.com/frappe/erpnext/issues/54137)) ([#54265](https://github.com/frappe/erpnext/issues/54265)) ([e0013f7](e0013f7618)), closes [#54488](https://github.com/frappe/erpnext/issues/54488)
* negative quantity check in validate_item_qty (backport [#54559](https://github.com/frappe/erpnext/issues/54559)) ([#54571](https://github.com/frappe/erpnext/issues/54571)) ([49ab25d](49ab25dda8))
* **payment_entry:** escape arguments on invoice and order fetching sql queries (backport [#54582](https://github.com/frappe/erpnext/issues/54582)) ([#54585](https://github.com/frappe/erpnext/issues/54585)) ([cceedd6](cceedd669f))
* **PCV:** set correct filters of `from_date` and `to_date` on General Ledger Report on clicking `Ledger` button (backport [#54522](https://github.com/frappe/erpnext/issues/54522)) ([#54523](https://github.com/frappe/erpnext/issues/54523)) ([6df39ae](6df39aec54))
* preserve inventory dimensions when raw materials are reset (backport [#54440](https://github.com/frappe/erpnext/issues/54440)) ([#54492](https://github.com/frappe/erpnext/issues/54492)) ([722dc8c](722dc8c3f1))
* **purchase_register:** filter tax rows by parenttype in invoice tax map query (backport [#54272](https://github.com/frappe/erpnext/issues/54272)) ([#54443](https://github.com/frappe/erpnext/issues/54443)) ([4dff436](4dff436104))
* py error on stock ageing report (backport [#54467](https://github.com/frappe/erpnext/issues/54467)) ([#54468](https://github.com/frappe/erpnext/issues/54468)) ([6179449](6179449036))
* sales order is not valid when creating WO from MR from PP (backport [#54435](https://github.com/frappe/erpnext/issues/54435)) ([#54470](https://github.com/frappe/erpnext/issues/54470)) ([9a4c693](9a4c693f2d))
* **stock:** remove validation for transfer_qty field (backport [#54542](https://github.com/frappe/erpnext/issues/54542)) ([#54544](https://github.com/frappe/erpnext/issues/54544)) ([8569ff6](8569ff67ff))
* **stock:** set incoming rate as zero for outward sle (backport [#54514](https://github.com/frappe/erpnext/issues/54514)) ([#54532](https://github.com/frappe/erpnext/issues/54532)) ([68d213a](68d213a244))
* unknown column error on item code in quality inspection ([#54565](https://github.com/frappe/erpnext/issues/54565)) ([e7a29ab](e7a29abdb0))
* update status of quotation in patch (backport [#54577](https://github.com/frappe/erpnext/issues/54577)) ([#54579](https://github.com/frappe/erpnext/issues/54579)) ([1a8dc7e](1a8dc7e332))
* use key consistently ([8f9a5e6](8f9a5e6c0c))

### Features

* danish_bosnian_address_template (backport [#54093](https://github.com/frappe/erpnext/issues/54093)) ([#54515](https://github.com/frappe/erpnext/issues/54515)) ([973444e](973444e20e))

### Reverts

* Revert "fix: preserve inventory dimensions when raw materials are reset (backport [#54440](https://github.com/frappe/erpnext/issues/54440))" ([#54507](https://github.com/frappe/erpnext/issues/54507)) ([1b08ac2](1b08ac248b))
* Revert "refactor: quality inspection item query (backport [#54511](https://github.com/frappe/erpnext/issues/54511))" ([#54557](https://github.com/frappe/erpnext/issues/54557)) ([f869e86](f869e86c9c)), closes [#54539](https://github.com/frappe/erpnext/issues/54539)
2026-04-28 21:00:34 +00:00
diptanilsaha
54b9392cc5 Merge pull request #54584 from frappe/version-15-hotfix 2026-04-29 02:28:40 +05:30
mergify[bot]
51e7c66043 fix: avoid double reduction of pe reference outstanding (backport #54193) (#54612)
* fix: avoid double reduction of pe reference outstanding (#54193)

Co-authored-by: diptanilsaha <diptanil@frappe.io>
(cherry picked from commit d1a80d40c4)

# Conflicts:
#	erpnext/accounts/utils.py

* chore: resolved conflict

* chore: remove unused import of DateTimeLikeObject

---------

Co-authored-by: Ravibharathi <131471282+ravibharathi656@users.noreply.github.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-04-28 20:35:46 +00:00
mergify[bot]
44f3f34c9e fix: add project filter to accounts payable and receivable reports (backport #54344) (#54441)
Co-authored-by: ljain112 <ljain112@gmail.com>
2026-04-28 22:39:42 +05:30
mergify[bot]
176d980764 fix: duplicate entries being shown in batch exists in future transact… (backport #54604) (#54605)
fix: duplicate entries being shown in batch exists in future transact… (#54604)

fix: duplicate entries being shown in batch exists in future transactions msg
(cherry picked from commit 54f20de7e3)

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-28 22:23:03 +05:30
mergify[bot]
44af175556 refactor(sms_center): replaced raw SQL queries with Query Builder (backport #54600) (#54602)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-04-28 15:31:58 +00:00
mergify[bot]
03f3a28f54 fix(get_stock_balance): validate inventory dimension fieldnames (backport #54587) (#54588)
* fix(`get_stock_balance`): validate inventory dimension fieldnames (#54587)

(cherry picked from commit 084c7f72f0)

# Conflicts:
#	erpnext/stock/utils.py

* chore: resolved conflicts

---------

Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-04-28 18:14:07 +05:30
mergify[bot]
cceedd669f fix(payment_entry): escape arguments on invoice and order fetching sql queries (backport #54582) (#54585)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
fix(payment_entry): escape arguments on invoice and order fetching sql queries (#54582)
2026-04-28 10:44:39 +00:00
mergify[bot]
1a8dc7e332 fix: update status of quotation in patch (backport #54577) (#54579)
fix: update status of quotation in patch (#54577)

(cherry picked from commit 2088a01c19)

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-28 15:13:16 +05:30
mergify[bot]
49ab25dda8 fix: negative quantity check in validate_item_qty (backport #54559) (#54571)
fix: negative quantity check in validate_item_qty (#54559)

Fix negative quantity check in validate_item_qty

When saving a Blanket Order with a blank qty field in the items table, the following error is raised:

TypeError: '<' not supported between instances of 'NoneType' and 'int'

Root cause: The validate_item_qty method compares d.qty < 0 directly. When the qty field is left empty, its value is None, and Python cannot compare None with an integer.

Fix
Wrap d.qty with flt(), which safely converts None (and any non-numeric value) to 0.0 before the comparison.

# Before
if d.qty < 0:

# After
if flt(d.qty) < 0:

(cherry picked from commit 63edd5ddc6)

Co-authored-by: Vinay Mishra <39999379+vinaymishraofficial@users.noreply.github.com>
2026-04-28 05:30:26 +00:00
Mihir Kandoi
e7a29abdb0 fix: unknown column error on item code in quality inspection (#54565) 2026-04-28 10:19:43 +05:30
mergify[bot]
78b2e45cb9 fix: debit credit not equal in purchase transactions for multi currency (backport #54456) (#54563)
fix: debit credit not equal in purchase transactions for multi currency (#54456)

(cherry picked from commit 601581d6f8)

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-27 15:14:58 +00:00
mergify[bot]
4dff436104 fix(purchase_register): filter tax rows by parenttype in invoice tax map query (backport #54272) (#54443)
Co-authored-by: ljain112 <ljain112@gmail.com>
2026-04-27 18:39:01 +05:30
Mihir Kandoi
f869e86c9c Revert "refactor: quality inspection item query (backport #54511)" (#54557)
Revert "refactor: quality inspection item query (backport #54511) (#54539)"

This reverts commit b01049814a.
2026-04-27 10:15:45 +00:00
mergify[bot]
8569ff67ff fix(stock): remove validation for transfer_qty field (backport #54542) (#54544)
fix(stock): remove validation for transfer_qty field (#54542)

(cherry picked from commit 60a6b38c31)

Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-04-27 07:12:24 +00:00
mergify[bot]
b01049814a refactor: quality inspection item query (backport #54511) (#54539)
* refactor: quality inspection item query (#54511)

(cherry picked from commit be2a4b7b2a)

# Conflicts:
#	erpnext/stock/doctype/quality_inspection/quality_inspection.py

* chore: resolve conflicts

---------

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-27 05:51:18 +00:00
mergify[bot]
973444e20e feat: danish_bosnian_address_template (backport #54093) (#54515)
feat: danish_bosnian_address_template (#54093)

(cherry picked from commit e517eeaaa2)

Co-authored-by: mahsem <137205921+mahsem@users.noreply.github.com>
2026-04-26 21:06:37 +05:30
mergify[bot]
68d213a244 fix(stock): set incoming rate as zero for outward sle (backport #54514) (#54532)
fix(stock): set incoming rate as zero for outward sle

(cherry picked from commit ce37530e70)

Co-authored-by: Sudharsanan11 <sudharsananashok1975@gmail.com>
2026-04-26 20:24:43 +05:30
mergify[bot]
6df39aec54 fix(PCV): set correct filters of from_date and to_date on General Ledger Report on clicking Ledger button (backport #54522) (#54523)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
fix(PCV): set correct filters of `from_date` and `to_date` on General Ledger Report on clicking `Ledger` button (#54522)
2026-04-25 00:06:32 +05:30
mergify[bot]
071a28ff8c refactor: use consistent report column names (backport #54451) (#54518)
* refactor: use consistent report column names

(cherry picked from commit 7630c01e40)

* refactor: better label for entity type

(cherry picked from commit 8e12bda108)

* fix: add party_type for dynamic link and add it to grouping key

(cherry picked from commit a3ad1fb163)

* fix: use key consistently

(cherry picked from commit 8f9a5e6c0c)

---------

Co-authored-by: Smit Vora <smitvora203@gmail.com>
2026-04-24 14:32:10 +00:00
Mihir Kandoi
1b08ac248b Revert "fix: preserve inventory dimensions when raw materials are reset (backport #54440)" (#54507)
Revert "fix: preserve inventory dimensions when raw materials are reset (back…"

This reverts commit 722dc8c3f1.
2026-04-24 08:43:59 +00:00
mergify[bot]
722dc8c3f1 fix: preserve inventory dimensions when raw materials are reset (backport #54440) (#54492)
* fix: preserve inventory dimensions when raw materials are reset (#54440)

* fix: preserve inventory dimensions when raw materials are reset

* test: add test case

(cherry picked from commit 0e20e35842)

# Conflicts:
#	erpnext/patches.txt
#	erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
#	erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py

* chore: resolve conflicts

* chore: resolve conflicts

* chore: resolve conflicts

---------

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-23 17:27:07 +00:00
mergify[bot]
e0013f7618 fix(edi): restrict Code List imports to files and trusted backend URLs (backport #54137) (#54265)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
fix(edi): restrict Code List imports to files and trusted backend URLs (#54137)
fix(edi): hardcode "Code List" DocType in importer (#54488)
2026-04-23 15:37:39 +00:00
Smit Vora
017635ab04 Merge pull request #54451 from vorasmit/tds-reports-refactor-backport 2026-04-23 15:25:46 +05:30
Smit Vora
8f9a5e6c0c fix: use key consistently 2026-04-23 15:01:46 +05:30
mergify[bot]
9a4c693f2d fix: sales order is not valid when creating WO from MR from PP (backport #54435) (#54470)
fix: sales order is not valid when creating WO from MR from PP (#54435)

(cherry picked from commit e65b9fc2ae)

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-22 15:05:29 +00:00
mergify[bot]
6179449036 fix: py error on stock ageing report (backport #54467) (#54468)
fix: py error on stock ageing report (#54467)

(cherry picked from commit f5357c233d)

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-22 14:32:21 +00:00
Smit Vora
a3ad1fb163 fix: add party_type for dynamic link and add it to grouping key 2026-04-22 12:12:18 +05:30
Smit Vora
8e12bda108 refactor: better label for entity type 2026-04-22 12:11:04 +05:30
Ravibharathi
947b282e0c Merge pull request #54452 from frappe/mergify/bp/version-15-hotfix/pr-54307
fix(accounts): fetch project name from payment entry to journal entry (backport #54307)
2026-04-22 11:24:29 +05:30
sarathibalamurugan
f9ae22d85e test: add test for project name in exchange gain loss entry
(cherry picked from commit 9eeb819106)

# Conflicts:
#	erpnext/accounts/doctype/payment_entry/test_payment_entry.py
2026-04-22 11:05:18 +05:30
Frappe PR Bot
b6902ef960 chore(release): Bumped to Version 15.105.0
# [15.105.0](https://github.com/frappe/erpnext/compare/v15.104.3...v15.105.0) (2026-04-21)

### Bug Fixes

* add portal user ownership check to supplier quotation (backport [#54298](https://github.com/frappe/erpnext/issues/54298)) ([#54299](https://github.com/frappe/erpnext/issues/54299)) ([1e4cafa](1e4cafaa0e))
* changed qty validation from qty field to stock_qty (backport [#54352](https://github.com/frappe/erpnext/issues/54352)) ([#54356](https://github.com/frappe/erpnext/issues/54356)) ([1ccbc9f](1ccbc9f621))
* clear conditions table when calculate_based_on is set to Fixed ([35bd437](35bd43775c))
* clear shipping rule conditions for fixed shipping rule ([9e10ecc](9e10ecc4cb))
* **dashboard-trends:** set default fiscal year and company before val… (backport [#54339](https://github.com/frappe/erpnext/issues/54339)) ([#54399](https://github.com/frappe/erpnext/issues/54399)) ([799f897](799f897036))
* fetch item tax template from item group when creating item ([#54405](https://github.com/frappe/erpnext/issues/54405)) ([ffa0268](ffa0268a57))
* move make_dimension_in_accounting_doctypes from after_insert to on_update (backport [#54172](https://github.com/frappe/erpnext/issues/54172)) ([#54317](https://github.com/frappe/erpnext/issues/54317)) ([d9d8fc6](d9d8fc6912))
* negative batch report showing same batch-warehouse multiple times ([3229fce](3229fce9a5))
* non-collapsible in customer quick entry ([9ee0594](9ee059465a))
* **pos_invoice_item:** fetch `grant_commission` from `item_code` (backport [#54413](https://github.com/frappe/erpnext/issues/54413)) ([#54417](https://github.com/frappe/erpnext/issues/54417)) ([813f464](813f4644a0))
* reset base_rounded_total when rounded_total resets (backport [#54241](https://github.com/frappe/erpnext/issues/54241)) ([#54303](https://github.com/frappe/erpnext/issues/54303)) ([28367ac](28367ac966))
* **vat audit report:** fallback to item name when item code is missing ([#54049](https://github.com/frappe/erpnext/issues/54049)) ([2c1ea8d](2c1ea8d30c))

### Features

* enhance tax withholding details report with additional columns support (backport [#54409](https://github.com/frappe/erpnext/issues/54409)) ([#54432](https://github.com/frappe/erpnext/issues/54432)) ([e223260](e22326065d))
2026-04-21 19:53:50 +00:00
diptanilsaha
489ff20021 Merge pull request #54438 from frappe/version-15-hotfix 2026-04-22 01:22:15 +05:30
diptanilsaha
bd957a9bbc Revert "feat: enhance tax withholding details report with additional columns support (backport #54409)" (#54458) 2026-04-21 18:49:08 +00:00
Lakshit Jain
e22326065d feat: enhance tax withholding details report with additional columns support (backport #54409) (#54432) 2026-04-22 00:02:19 +05:30
sarathibalamurugan
55cce2a11c fix(accounts): fetch project name from payment entry to journal entry
(cherry picked from commit d9b255b952)
2026-04-21 13:29:16 +00:00
Smit Vora
7630c01e40 refactor: use consistent report column names 2026-04-21 18:53:25 +05:30
Ravibharathi
bd4eb71205 Merge pull request #54423 from frappe/mergify/bp/version-15-hotfix/pr-54415
fix: clear conditions table when calculate_based_on is set to Fixed (backport #54415)
2026-04-20 19:44:36 +05:30
ravibharathi656
9e10ecc4cb fix: clear shipping rule conditions for fixed shipping rule
(cherry picked from commit d6bb0ae093)
2026-04-20 13:53:03 +00:00
sarathibalamurugan
35bd43775c fix: clear conditions table when calculate_based_on is set to Fixed
(cherry picked from commit d73920be12)
2026-04-20 13:53:02 +00:00
mergify[bot]
813f4644a0 fix(pos_invoice_item): fetch grant_commission from item_code (backport #54413) (#54417)
* fix(pos_invoice_item): fetch `grant_commission` from `item_code` (#54413)

(cherry picked from commit 6c51e4cd1f)

# Conflicts:
#	erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json

* chore: resolve conflicts

---------

Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-04-20 11:48:06 +00:00
Ravibharathi
2c1ea8d30c fix(vat audit report): fallback to item name when item code is missing (#54049)
* fix(vat audit report): fallback to item name when item code is missing

* fix: validate south africa company selection

* fix: simplify parent item lookup

* fix: handle missing item mapping

* fix: use list instead of set
2026-04-20 15:56:37 +05:30
Pandiyan P
ffa0268a57 fix: fetch item tax template from item group when creating item (#54405) 2026-04-20 11:58:16 +05:30
mergify[bot]
1ccbc9f621 fix: changed qty validation from qty field to stock_qty (backport #54352) (#54356)
fix: changed qty validation from qty field to stock_qty (#54352)

(cherry picked from commit ba01d66c24)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-04-20 10:53:44 +05:30
mergify[bot]
799f897036 fix(dashboard-trends): set default fiscal year and company before val… (backport #54339) (#54399)
* fix(dashboard-trends): set default fiscal year and company before val… (#54339)

* fix(dashboard-trends): set default fiscal year and company before validating filters Ensure  and  are populated with default values

* fix(dashboard-trends): ensure fiscal_year and company are properly set before validation to avoid empty filter issues

* Update erpnext/controllers/trends.py

---------

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit d61b5fd5f6)

# Conflicts:
#	erpnext/controllers/trends.py

* chore: resolve conflicts

---------

Co-authored-by: Ahmed AbuKhatwa <82771130+AhmedAbokhatwa@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-19 19:34:19 +05:30
mergify[bot]
6b7bdfdfd3 Fix : None handling in pricing rule free item quantity calculation (backport #54375) (#54395)
Fix : None handling in pricing rule free item quantity calculation (#54375)

* fix(pricing_rule): handle None qty in transaction_qty calculation

* Update erpnext/accounts/doctype/pricing_rule/utils.py

---------



(cherry picked from commit 82438d6c72)

Co-authored-by: Jaganath-Tridots <jaganath@tridotstech.com>
Co-authored-by: Jagan <jagan@DESKTOP-HPDMQ06.localdomain>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-19 07:57:37 +00:00
rohitwaghchaure
e3374933ed Merge pull request #54359 from frappe/mergify/bp/version-15-hotfix/pr-54354
fix: negative batch report showing same batch-warehouse multiple times (backport #54354)
2026-04-17 21:26:16 +05:30
Rohit Waghchaure
3229fce9a5 fix: negative batch report showing same batch-warehouse multiple times
(cherry picked from commit 700572980d)
2026-04-17 15:41:47 +00:00
mergify[bot]
d9d8fc6912 fix: move make_dimension_in_accounting_doctypes from after_insert to on_update (backport #54172) (#54317)
* fix: move make_dimension_in_accounting_doctypes from after_insert to on_update

(cherry picked from commit ee067e6015)

# Conflicts:
#	erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py

* chore: resolve conflicts in accounting_dimension.py

---------

Co-authored-by: Shllokkk <shllokosan23@gmail.com>
2026-04-17 15:14:17 +05:30
mergify[bot]
28367ac966 fix: reset base_rounded_total when rounded_total resets (backport #54241) (#54303)
* fix: reset base_rounded_total when rounded_total resets

(cherry picked from commit f8d278b733)

# Conflicts:
#	erpnext/controllers/tests/test_taxes_and_totals.py
#	erpnext/public/js/controllers/taxes_and_totals.js

* chore: spelling mistake

(cherry picked from commit e2ac476587)

* chore: resolve conflicts

---------

Co-authored-by: ljain112 <ljain112@gmail.com>
2026-04-16 10:39:57 +05:30
NaviN
67632e81d0 Merge pull request #54308 from frappe/mergify/bp/version-15-hotfix/pr-54306
fix: non-collapsible in customer quick entry (backport #54306)
2026-04-15 17:32:16 +05:30
PKSowmiya05
9ee059465a fix: non-collapsible in customer quick entry
(cherry picked from commit 53e120269d)
2026-04-15 11:56:39 +00:00
mergify[bot]
1e4cafaa0e fix: add portal user ownership check to supplier quotation (backport #54298) (#54299)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: add portal user ownership check to supplier quotation (#54298)
2026-04-15 06:07:23 +00:00
Frappe PR Bot
fc3ceff42f chore(release): Bumped to Version 15.104.3
## [15.104.3](https://github.com/frappe/erpnext/compare/v15.104.2...v15.104.3) (2026-04-14)

### Bug Fixes

* account change in warehouse (backport [#54182](https://github.com/frappe/erpnext/issues/54182)) ([#54204](https://github.com/frappe/erpnext/issues/54204)) ([430705f](430705f56c))
* hardcoded precision causing decimal issues ([7754504](77545042a5))
* inventory dimension patch (backport [#54141](https://github.com/frappe/erpnext/issues/54141)) ([#54145](https://github.com/frappe/erpnext/issues/54145)) ([deb67db](deb67db4a0))
* inventory dimension patch (backport [#54147](https://github.com/frappe/erpnext/issues/54147)) ([#54148](https://github.com/frappe/erpnext/issues/54148)) ([a56d698](a56d6984d1))
* inventory dimensions should not be mandatory unnecesarily (backport [#54064](https://github.com/frappe/erpnext/issues/54064)) ([#54133](https://github.com/frappe/erpnext/issues/54133)) ([a26c845](a26c845332))
* last SLE not updated in the file ([8408e81](8408e81335))
* **list_opportunity_report:** parameterized `lost_reason` ([#54160](https://github.com/frappe/erpnext/issues/54160)) ([1604c21](1604c21602))
* make operation mandatory when any sub operation row is added (backport [#54245](https://github.com/frappe/erpnext/issues/54245)) ([#54247](https://github.com/frappe/erpnext/issues/54247)) ([cbe5ad6](cbe5ad6337))
* preserve asset movement field properties after save ([a87015e](a87015e8e6))
* quality inspection item code fetch perm issue (backport [#54121](https://github.com/frappe/erpnext/issues/54121)) ([#54126](https://github.com/frappe/erpnext/issues/54126)) ([bcd6d99](bcd6d99549))
* remove unneccessary function for serial no status updation (backport [#54191](https://github.com/frappe/erpnext/issues/54191)) ([#54196](https://github.com/frappe/erpnext/issues/54196)) ([cb24d94](cb24d9404d))
* **sales invoice:** toggle Get Items From button based on is_return and POS view (backport [#52594](https://github.com/frappe/erpnext/issues/52594)) ([#54138](https://github.com/frappe/erpnext/issues/54138)) ([5de4102](5de4102dda))
* sanitize genericode import inputs and secure XML parser (backport [#53302](https://github.com/frappe/erpnext/issues/53302)) ([#54174](https://github.com/frappe/erpnext/issues/54174)) ([76e910e](76e910e8c0))
* set default posting time in RIV ([6e438e7](6e438e71eb))
* **stock:** remove float precision to fix precision issue (backport [#54284](https://github.com/frappe/erpnext/issues/54284)) ([#54288](https://github.com/frappe/erpnext/issues/54288)) ([0e9b3b4](0e9b3b459a))
* **stock:** update bin to zero when no previous sle exists (backport [#54236](https://github.com/frappe/erpnext/issues/54236)) ([#54263](https://github.com/frappe/erpnext/issues/54263)) ([46a1c6f](46a1c6fda0))
* update return value in workstation list view indicator (backport [#54198](https://github.com/frappe/erpnext/issues/54198)) ([#54200](https://github.com/frappe/erpnext/issues/54200)) ([0a3f9f0](0a3f9f0b9f))
* update_nsm only in warehouse creation ([#54165](https://github.com/frappe/erpnext/issues/54165)) ([e9c1a09](e9c1a09af3))
2026-04-14 18:20:17 +00:00
diptanilsaha
c74a44e526 Merge pull request #54282 from frappe/version-15-hotfix 2026-04-14 23:48:43 +05:30
mergify[bot]
8b3d65ae78 Revert "fix: sync paid and received amount" (backport #54238) (#54292)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
fix: sync paid and received amount" (#54238)
2026-04-14 22:27:04 +05:30
mergify[bot]
0e9b3b459a fix(stock): remove float precision to fix precision issue (backport #54284) (#54288)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
fix(stock): remove float precision to fix precision issue (#54284)
2026-04-14 11:33:23 +00:00
mergify[bot]
46a1c6fda0 fix(stock): update bin to zero when no previous sle exists (backport #54236) (#54263)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
fix(stock): update bin to zero when no previous sle exists (#54236)
2026-04-13 15:49:45 +00:00
mergify[bot]
cbe5ad6337 fix: make operation mandatory when any sub operation row is added (backport #54245) (#54247)
Co-authored-by: Sudarshan <73628063+sudarsan2001@users.noreply.github.com>
fix: make operation mandatory when any sub operation row is added (#54245)
2026-04-13 21:06:05 +05:30
mergify[bot]
17ce550417 Fix(bom): refetch the rate of item when 'source_from_supplier' is updated (backport #54187) (#54207)
Co-authored-by: Sambhav Saxena <76242518+sambhavsaxena@users.noreply.github.com>
Fix(bom): refetch the rate of item when 'source_from_supplier' is updated (#54187)
2026-04-10 23:44:55 +05:30
mergify[bot]
430705f56c fix: account change in warehouse (backport #54182) (#54204)
Co-authored-by: nishkagosalia <nishka.gosalia@gmail.com>
2026-04-10 20:32:33 +05:30
mergify[bot]
0a3f9f0b9f fix: update return value in workstation list view indicator (backport #54198) (#54200)
Co-authored-by: Praveenkumar Dhanasekar <164200710+Praveenku-mar@users.noreply.github.com>
fix: update return value in workstation list view indicator (#54198)
2026-04-10 16:50:32 +05:30
mergify[bot]
cb24d9404d fix: remove unneccessary function for serial no status updation (backport #54191) (#54196)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: remove unneccessary function for serial no status updation (#54191)
2026-04-10 10:53:04 +00:00
Nishka Gosalia
bc6780d4c7 Merge pull request #54179 from frappe/revert-54170-mergify/bp/version-15-hotfix/pr-54165
fix: update_nsm only in warehouse creation (backport #54165)"
2026-04-09 18:31:00 +05:30
Nishka Gosalia
8b16c310f4 Revert "fix: update_nsm only in warehouse creation (backport #54165)" 2026-04-09 18:12:46 +05:30
mergify[bot]
76e910e8c0 fix: sanitize genericode import inputs and secure XML parser (backport #53302) (#54174)
Co-authored-by: Shllokkk <shllokosan23@gmail.com>
2026-04-09 11:30:24 +00:00
Frappe PR Bot
8aede87290 chore(release): Bumped to Version 15.104.2
## [15.104.2](https://github.com/frappe/erpnext/compare/v15.104.1...v15.104.2) (2026-04-09)

### Bug Fixes

* set default posting time in RIV ([041f99c](041f99c926))
2026-04-09 11:15:07 +00:00
rohitwaghchaure
1b2c7ca21f Merge pull request #54169 from frappe/mergify/bp/version-15/pr-54162
fix: set default posting time in RIV (backport #54161) (backport #54162)
2026-04-09 16:43:37 +05:30
Nishka Gosalia
c44ec7eab4 Merge pull request #54170 from frappe/mergify/bp/version-15-hotfix/pr-54165
fix: update_nsm only in warehouse creation (backport #54165)
2026-04-09 16:29:32 +05:30
Nishka Gosalia
e9c1a09af3 fix: update_nsm only in warehouse creation (#54165)
(cherry picked from commit b0e3fa3979)
2026-04-09 10:28:14 +00:00
rohitwaghchaure
db3a40409f chore: fix conflicts
Removed unused method reset_repost_only_accounting_ledgers and fixed the validate method to set default posting time.

(cherry picked from commit 2df574baae)
2026-04-09 09:58:50 +00:00
Rohit Waghchaure
041f99c926 fix: set default posting time in RIV
(cherry picked from commit a7ece65536)

# Conflicts:
#	erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
(cherry picked from commit 6e438e71eb)
2026-04-09 09:58:50 +00:00
rohitwaghchaure
8843068da9 Merge pull request #54162 from frappe/mergify/bp/version-15-hotfix/pr-54161
fix: set default posting time in RIV (backport #54161)
2026-04-09 15:28:12 +05:30
rohitwaghchaure
2df574baae chore: fix conflicts
Removed unused method reset_repost_only_accounting_ledgers and fixed the validate method to set default posting time.
2026-04-09 14:24:07 +05:30
Rohit Waghchaure
6e438e71eb fix: set default posting time in RIV
(cherry picked from commit a7ece65536)

# Conflicts:
#	erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
2026-04-09 08:26:57 +00:00
diptanilsaha
1604c21602 fix(list_opportunity_report): parameterized lost_reason (#54160) 2026-04-09 07:23:12 +00:00
Aarol D'Souza
97c4cd140b Merge pull request #54157 from frappe/mergify/bp/version-15-hotfix/pr-54129
refactor: update reset password method name (backport #54129)
2026-04-09 12:30:37 +05:30
mergify[bot]
9d64d4ac05 Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-54129 2026-04-09 06:42:06 +00:00
mergify[bot]
5de4102dda fix(sales invoice): toggle Get Items From button based on is_return and POS view (backport #52594) (#54138)
Co-authored-by: NaviN <118178330+Navin-S-R@users.noreply.github.com>
Co-authored-by: Navin-S-R <navin@aerele.in>
fix(sales invoice): toggle Get Items From button based on is_return and POS view (#52594)
2026-04-09 11:58:07 +05:30
AarDG10
39a473455d refactor: update reset password method name
(cherry picked from commit c4d74483e1)
2026-04-09 06:23:37 +00:00
Frappe PR Bot
b88f3f69b0 chore(release): Bumped to Version 15.104.1
## [15.104.1](https://github.com/frappe/erpnext/compare/v15.104.0...v15.104.1) (2026-04-09)

### Bug Fixes

* last SLE not updated in the file ([3a2dc6f](3a2dc6f9ee))
2026-04-09 05:20:24 +00:00
rohitwaghchaure
dba8abbabf Merge pull request #54154 from frappe/mergify/bp/version-15/pr-54150
fix: last SLE not updated in the file (backport #54132) (backport #54150)
2026-04-09 10:48:53 +05:30
rohitwaghchaure
c1591c37db chore: fix conflicts
(cherry picked from commit c70259687a)
2026-04-09 04:48:38 +00:00
Rohit Waghchaure
3a2dc6f9ee fix: last SLE not updated in the file
(cherry picked from commit 38ed425ee2)

# Conflicts:
#	erpnext/manufacturing/doctype/work_order/test_work_order.py
(cherry picked from commit 8408e81335)
2026-04-09 04:48:38 +00:00
rohitwaghchaure
4f1203dbd0 Merge pull request #54150 from frappe/mergify/bp/version-15-hotfix/pr-54132
fix: last SLE not updated in the file (backport #54132)
2026-04-09 10:17:56 +05:30
rohitwaghchaure
c70259687a chore: fix conflicts 2026-04-09 09:06:31 +05:30
Rohit Waghchaure
8408e81335 fix: last SLE not updated in the file
(cherry picked from commit 38ed425ee2)

# Conflicts:
#	erpnext/manufacturing/doctype/work_order/test_work_order.py
2026-04-09 02:53:01 +00:00
mergify[bot]
a56d6984d1 fix: inventory dimension patch (backport #54147) (#54148) 2026-04-09 02:40:40 +00:00
mergify[bot]
deb67db4a0 fix: inventory dimension patch (backport #54141) (#54145)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: inventory dimension patch (#54141)
2026-04-09 02:01:45 +00:00
mergify[bot]
a26c845332 fix: inventory dimensions should not be mandatory unnecesarily (backport #54064) (#54133)
* fix: inventory dimensions should not be mandatory unnecesarily (#54064)

(cherry picked from commit 6e44b8913e)

# Conflicts:
#	erpnext/patches.txt
#	erpnext/stock/doctype/inventory_dimension/inventory_dimension.py

* chore: resolve conflicts

* chore: resolve conflicts

* chore: resolve conflicts

---------

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-08 14:23:20 +00:00
mergify[bot]
bcd6d99549 fix: quality inspection item code fetch perm issue (backport #54121) (#54126)
Co-authored-by: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: quality inspection item code fetch perm issue (#54121)
2026-04-08 12:05:39 +00:00
rohitwaghchaure
d39d076fba Merge pull request #54118 from frappe/mergify/bp/version-15-hotfix/pr-54102
fix: hardcoded precision causing decimal issues (backport #54102)
2026-04-08 14:25:07 +05:30
rohitwaghchaure
21607f39c5 chore: fix conflicts 2026-04-08 14:05:04 +05:30
rohitwaghchaure
39a4760e07 chore: fix conflicts 2026-04-08 12:29:00 +05:30
rohitwaghchaure
d2c6a8958d chore: fix conflicts
Updated the modified date for the delivery note item.
2026-04-08 12:28:29 +05:30
Rohit Waghchaure
77545042a5 fix: hardcoded precision causing decimal issues
(cherry picked from commit 90fd6f2e40)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
#	erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
#	erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
2026-04-08 06:49:57 +00:00
Khushi Rawat
13f4ba1857 Merge pull request #54116 from frappe/mergify/bp/version-15-hotfix/pr-54103
fix: preserve asset movement field properties after save (backport #54103)
2026-04-08 12:15:45 +05:30
ravibharathi656
a87015e8e6 fix: preserve asset movement field properties after save
(cherry picked from commit 4a004a2a82)
2026-04-08 06:28:09 +00:00
Frappe PR Bot
a2626ed55f chore(release): Bumped to Version 15.104.0
# [15.104.0](https://github.com/frappe/erpnext/compare/v15.103.1...v15.104.0) (2026-04-07)

### Bug Fixes

* add support to fetch items based on manufacture stock entry; fix how it's done from work order ([e9ce0a4](e9ce0a41e6))
* add v15 compatibility for scrap item ([652bd39](652bd396d4))
* auto-set source_stock_entry ([b87b445](b87b445802))
* avg stock entries for disassembly from WO ([44d4079](44d40795df))
* correct warehouse preference for disassemble ([b8ddc2f](b8ddc2f2b9))
* create source_stock_entry to refer to original manufacturing entry ([55ee1dc](55ee1dcd04))
* custom button to disassemble manufactured stock entry with work order ([835ae27](835ae27b38))
* disassembly prompt with source stock entry field ([44f2e94](44f2e9480d))
* do not repost GL if no change in valuation ([0063201](0063201818))
* do not show inv dimension unnecessarily in stock entry (backport [#53946](https://github.com/frappe/erpnext/issues/53946)) ([#53950](https://github.com/frappe/erpnext/issues/53950)) ([e159c79](e159c79766))
* ensure compatibility with v15 ([8b42fcf](8b42fcf274))
* GL entries for different exchange rate in the purchase invoice ([def62cf](def62cf3fe))
* handle disassembly for secondary / scrap items ([229dc23](229dc23f97))
* include rejected qty in tax (purchase receipt) (backport [#53624](https://github.com/frappe/erpnext/issues/53624)) ([#53971](https://github.com/frappe/erpnext/issues/53971)) ([3fbfad1](3fbfad1b9b))
* manufacture entry with group_by support ([841b507](841b507502))
* **manufacturing:** handle null cur_dialog in BOM work order dialog (backport [#54011](https://github.com/frappe/erpnext/issues/54011)) ([#54014](https://github.com/frappe/erpnext/issues/54014)) ([cb0a548](cb0a548a95))
* not able to set operation in work order ([62d5870](62d58702a0))
* prevent selection of group type customer group in customer master ([7a227e0](7a227e048e))
* process loss with bom path disassembly ([eee6d7e](eee6d7e566))
* **promotional_scheme:** toggle enable state between Buying and Selli… (backport [#54110](https://github.com/frappe/erpnext/issues/54110)) ([#54111](https://github.com/frappe/erpnext/issues/54111)) ([5b7e6eb](5b7e6eb831))
* remove reference in serial/batch when document is cancelled (backport [#53979](https://github.com/frappe/erpnext/issues/53979)) ([#53988](https://github.com/frappe/erpnext/issues/53988)) ([e33abee](e33abeef7f))
* remove unnecessary param, and use value from self ([0b0dccd](0b0dccd294))
* resolve user permission error on status change by updating user … (backport [#54033](https://github.com/frappe/erpnext/issues/54033)) ([#54059](https://github.com/frappe/erpnext/issues/54059)) ([14085de](14085de332))
* set bom details on disassembly; abs batch qty ([84d5b52](84d5b52483))
* set serial and batch from source stock entry - on disassemble ([df049cd](df049cd277))
* set_query for source stock entry ([849b2e6](849b2e6ebf))
* show current stock qty in Stock Entry PDF (backport [#53761](https://github.com/frappe/erpnext/issues/53761)) ([#54031](https://github.com/frappe/erpnext/issues/54031)) ([af0116c](af0116cdc5))
* skip discount amount validation when not saving ([13eab9f](13eab9f993))
* **stock:** update stock queue in SABE for return entries ([05d6cf5](05d6cf5c9a))
* support creating disassembly (without link of WO) ([ef15c05](ef15c0581d))
* sync paid and received amount (backport [#53039](https://github.com/frappe/erpnext/issues/53039)) ([#54107](https://github.com/frappe/erpnext/issues/54107)) ([0505684](0505684d22))
* **test:** do not use is_group enabled customer group in test ([97684d3](97684d3dae))
* **test:** pin posting date in test_depreciation_on_cancel_invoice ([7f72189](7f72189665))
* **test:** use non-group customer group in test setup ([ea3fcc2](ea3fcc214b))
* transactions where update stock is 0 should not create SLEs (backport [#54035](https://github.com/frappe/erpnext/issues/54035)) ([#54076](https://github.com/frappe/erpnext/issues/54076)) ([bcf59e7](bcf59e7171))
* update min date based on transaction_date (backport [#53803](https://github.com/frappe/erpnext/issues/53803)) ([#54024](https://github.com/frappe/erpnext/issues/54024)) ([a71d32e](a71d32e668))
* use get_value ([8f01d12](8f01d12b5e))
* **ux:** refresh grid to correctly persist the state of fields ([3c327d5](3c327d5225))
* validate qty that can be disassembled from source stock entry. ([583c7b9](583c7b9819))
* validate work order consistency in stock entry ([d690a0c](d690a0c6bd))
* validation test for customer group ([7794f30](7794f3033e))
* **warehouse_capacity_dashboard:** removed `escape` from template (backport [#53907](https://github.com/frappe/erpnext/issues/53907)) ([#53908](https://github.com/frappe/erpnext/issues/53908)) ([efdb004](efdb004f0b))

### Features

* Allow Editing of Items and Quantities in Work Order ([1d36cb5](1d36cb55cd))
* croatian_address_template (backport [#53888](https://github.com/frappe/erpnext/issues/53888)) ([#54057](https://github.com/frappe/erpnext/issues/54057)) ([ee81268](ee812687e6))
* **timesheet:** allow partial billing and handled return ([21805bd](21805bde1f))

### Reverts

* botched backport ([#53967](https://github.com/frappe/erpnext/issues/53967)) ([22774fd](22774fdf87)), closes [#53776](https://github.com/frappe/erpnext/issues/53776) [#53766](https://github.com/frappe/erpnext/issues/53766) [#53767](https://github.com/frappe/erpnext/issues/53767)
2026-04-07 18:01:31 +00:00
diptanilsaha
0cc77274cb Merge pull request #54101 from frappe/version-15-hotfix 2026-04-07 22:19:03 +05:30
mergify[bot]
5b7e6eb831 fix(promotional_scheme): toggle enable state between Buying and Selli… (backport #54110) (#54111)
Co-authored-by: Ahmed AbuKhatwa <82771130+AhmedAbokhatwa@users.noreply.github.com>
Co-authored-by: AhmedAbukhatwa <Ahmedabukhatwa1@gmail.com>
fix(promotional_scheme): toggle enable state between Buying and Selli… (#54110)
2026-04-07 21:55:06 +05:30
rohitwaghchaure
1fb9c5244c Merge pull request #54009 from frappe/mergify/bp/version-15-hotfix/pr-53994
fix(stock): update stock queue in SABE for return entries (backport #53994)
2026-04-07 19:29:19 +05:30
rohitwaghchaure
e68eece3da Merge pull request #53804 from frappe/mergify/bp/version-15-hotfix/pr-52152
Refactor reposting feature (backport #52152)
2026-04-07 19:28:35 +05:30
Smit Vora
b8063a07fc Merge pull request #54097 from frappe/mergify/bp/version-15-hotfix/pr-53964
fix: consistently disassemble based on source  > SE / WO / BOM (backport #53964)
2026-04-07 19:27:23 +05:30
Smit Vora
8b42fcf274 fix: ensure compatibility with v15 2026-04-07 19:09:51 +05:30
Rohit Waghchaure
0063201818 fix: do not repost GL if no change in valuation 2026-04-07 18:52:10 +05:30
Rohit Waghchaure
2f9643d44d refactor: reposting for better peformance
(cherry picked from commit 20787ef5da)
2026-04-07 18:51:17 +05:30
mergify[bot]
0505684d22 fix: sync paid and received amount (backport #53039) (#54107)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
fix: sync paid and received amount (#53039)
2026-04-07 13:06:02 +00:00
Nishka Gosalia
0b958136be Merge pull request #53996 from aerele/partial-billing-timesheet 2026-04-07 16:11:18 +05:30
Smit Vora
652bd396d4 fix: add v15 compatibility for scrap item 2026-04-07 15:43:43 +05:30
Smit Vora
904ac62830 chore: resolve conflicts 2026-04-07 14:51:35 +05:30
Smit Vora
2fee39017c Merge pull request #53974 from vorasmit/backport-50407-50856 2026-04-07 14:17:55 +05:30
Smit Vora
0b0dccd294 fix: remove unnecessary param, and use value from self
(cherry picked from commit 98dfd64f63)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.py
2026-04-07 08:47:49 +00:00
Smit Vora
99df61a0d8 test: enhance tests as per review comments
(cherry picked from commit f13d37fbf9)
2026-04-07 08:47:48 +00:00
Smit Vora
7767659b87 test: maintain sufficient stock for scrap item
(cherry picked from commit b892139342)
2026-04-07 08:47:48 +00:00
Smit Vora
84d5b52483 fix: set bom details on disassembly; abs batch qty
(cherry picked from commit ab1fc22431)
2026-04-07 08:47:48 +00:00
Smit Vora
eee6d7e566 fix: process loss with bom path disassembly
(cherry picked from commit 93ad48bc1b)

# Conflicts:
#	erpnext/manufacturing/doctype/work_order/test_work_order.py
2026-04-07 08:47:47 +00:00
Smit Vora
d690a0c6bd fix: validate work order consistency in stock entry
(cherry picked from commit ea392b2009)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.py
2026-04-07 08:47:47 +00:00
vorasmit
8f01d12b5e fix: use get_value
(cherry picked from commit a71e8bb116)
2026-04-07 08:47:46 +00:00
vorasmit
44d40795df fix: avg stock entries for disassembly from WO
(cherry picked from commit 71fd18bdf9)
2026-04-07 08:47:46 +00:00
vorasmit
841b507502 fix: manufacture entry with group_by support
(cherry picked from commit 3cf1ce8360)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.py
2026-04-07 08:47:46 +00:00
Smit Vora
20f81516cf test: disassembly for scrap / secondary item
(cherry picked from commit a6d41151ff)
2026-04-07 08:47:45 +00:00
Smit Vora
229dc23f97 fix: handle disassembly for secondary / scrap items
(cherry picked from commit 2be8313819)
2026-04-07 08:47:45 +00:00
Smit Vora
f9b1df3572 test: disassembly of items with batch and serial numbers
(cherry picked from commit 1693698fed)
2026-04-07 08:47:44 +00:00
Smit Vora
1063a56251 test: additional items in stock entry considered with disassembly
(cherry picked from commit d32977e3a9)
2026-04-07 08:47:44 +00:00
Smit Vora
75eb5ad584 test: disassemble with source stock entry reference
(cherry picked from commit 6988e2cbbc)
2026-04-07 08:47:44 +00:00
Smit Vora
43c507570b test: disassembly from wo
(cherry picked from commit 342a14d340)
2026-04-07 08:47:43 +00:00
Smit Vora
df049cd277 fix: set serial and batch from source stock entry - on disassemble
(cherry picked from commit 13b019ab8e)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.py
2026-04-07 08:47:43 +00:00
Smit Vora
b8ddc2f2b9 fix: correct warehouse preference for disassemble
(cherry picked from commit d3d6b5c660)
2026-04-07 08:47:43 +00:00
Smit Vora
b87b445802 fix: auto-set source_stock_entry
(cherry picked from commit 2e4e8bcaa7)
2026-04-07 08:47:42 +00:00
Smit Vora
e9ce0a41e6 fix: add support to fetch items based on manufacture stock entry; fix how it's done from work order
(cherry picked from commit 1ed0124ad7)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.py
2026-04-07 08:47:42 +00:00
Smit Vora
583c7b9819 fix: validate qty that can be disassembled from source stock entry.
(cherry picked from commit 6394dead72)

# Conflicts:
#	erpnext/manufacturing/doctype/work_order/work_order.py
2026-04-07 08:47:41 +00:00
Smit Vora
ef15c0581d fix: support creating disassembly (without link of WO)
(cherry picked from commit dba82720b6)
2026-04-07 08:47:41 +00:00
Smit Vora
835ae27b38 fix: custom button to disassemble manufactured stock entry with work order
(cherry picked from commit b64f86148c)
2026-04-07 08:47:41 +00:00
Smit Vora
849b2e6ebf fix: set_query for source stock entry
(cherry picked from commit b47dfacb3e)
2026-04-07 08:47:40 +00:00
Smit Vora
44f2e9480d fix: disassembly prompt with source stock entry field
(cherry picked from commit 68e97808c5)

# Conflicts:
#	erpnext/manufacturing/doctype/work_order/work_order.js
#	erpnext/manufacturing/doctype/work_order/work_order.py
2026-04-07 08:47:40 +00:00
Smit Vora
55ee1dcd04 fix: create source_stock_entry to refer to original manufacturing entry
(cherry picked from commit d4baa9a74a)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.json
#	erpnext/stock/doctype/stock_entry/stock_entry.py
2026-04-07 08:47:40 +00:00
rohitwaghchaure
c81c1ea869 chore: fix test case 2026-04-07 13:11:44 +05:30
rohitwaghchaure
831ddcd5af Merge pull request #54068 from frappe/mergify/bp/version-15-hotfix/pr-54050
fix: GL entries for different exchange rate in the purchase invoice (backport #54050)
2026-04-07 13:10:40 +05:30
mergify[bot]
bcf59e7171 fix: transactions where update stock is 0 should not create SLEs (backport #54035) (#54076)
* fix: transactions where update stock is 0 should not create SLEs (#54035)

(cherry picked from commit 66780543bd)

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

* chore: resolve conflicts

* chore: resolve conflicts

---------

Co-authored-by: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-04-07 06:31:51 +00:00
mergify[bot]
ee812687e6 feat: croatian_address_template (backport #53888) (#54057)
Co-authored-by: mahsem <137205921+mahsem@users.noreply.github.com>
2026-04-07 10:26:03 +05:30
mergify[bot]
14085de332 fix: resolve user permission error on status change by updating user … (backport #54033) (#54059)
Co-authored-by: Krishna Shirsath <shirsathkrishna19@gmail.com>
2026-04-07 10:25:32 +05:30
Khushi Rawat
22652f30db Merge pull request #53956 from frappe/mergify/bp/version-15-hotfix/pr-53811
fix: prevent selection of group type customer group in customer master (backport #53811)
2026-04-07 03:16:36 +05:30
khushi8112
7794f3033e fix: validation test for customer group 2026-04-07 02:59:32 +05:30
Poovitha Palanivelu
21805bde1f feat(timesheet): allow partial billing and handled return 2026-04-06 23:04:23 +05:30
rohitwaghchaure
93bfd62725 chore: fix conflicts
Removed redundant calculation of billed quantity and adjusted logic for billed amount based on purchase order.
2026-04-06 17:44:36 +05:30
Rohit Waghchaure
def62cf3fe fix: GL entries for different exchange rate in the purchase invoice
(cherry picked from commit a953709640)

# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
2026-04-06 11:52:49 +00:00
khushi8112
ea3fcc214b fix(test): use non-group customer group in test setup 2026-04-06 16:09:05 +05:30
diptanilsaha
1146c9550a Merge pull request #54046 from frappe/mergify/bp/version-15-hotfix/pr-54042
fix: skip discount amount validation when not saving (backport #54042)
2026-04-06 13:44:03 +05:30
rohitwaghchaure
c5edeae97e Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-53994 2026-04-06 13:35:08 +05:30
rohitwaghchaure
f855cc89c9 chore: fix conflicts 2026-04-06 13:12:03 +05:30
Sagar Vora
1ffbc399e1 test: add test for discount amount on partial purchase receipt
Co-authored-by: ravibharathi656 <131471282+ravibharathi656@users.noreply.github.com>
(cherry picked from commit 135cb5fd67)
2026-04-06 07:30:43 +00:00
Sagar Vora
13eab9f993 fix: skip discount amount validation when not saving
(cherry picked from commit 0975583388)
2026-04-06 07:30:43 +00:00
khushi8112
97684d3dae fix(test): do not use is_group enabled customer group in test
(cherry picked from commit 75fa2b2277)
2026-04-06 12:57:49 +05:30
khushi8112
7a227e048e fix: prevent selection of group type customer group in customer master
(cherry picked from commit 6068dc959f)
2026-04-06 12:57:49 +05:30
mergify[bot]
af0116cdc5 fix: show current stock qty in Stock Entry PDF (backport #53761) (#54031) 2026-04-06 05:36:09 +00:00
mergify[bot]
a71d32e668 fix: update min date based on transaction_date (backport #53803) (#54024)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
fix: update min date based on transaction_date (#53803)
2026-04-05 21:16:48 +05:30
mergify[bot]
e33abeef7f fix: remove reference in serial/batch when document is cancelled (backport #53979) (#53988) 2026-04-05 15:39:20 +00:00
mergify[bot]
cb0a548a95 fix(manufacturing): handle null cur_dialog in BOM work order dialog (backport #54011) (#54014) 2026-04-05 12:48:16 +05:30
kavin-114
b57db06100 test(stock): add unit test to update stock queue for return
(cherry picked from commit e537896df8)

# Conflicts:
#	erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
2026-04-04 21:17:08 +00:00
kavin-114
05d6cf5c9a fix(stock): update stock queue in SABE for return entries
(cherry picked from commit 0af8077bcc)
2026-04-04 21:17:08 +00:00
Khushi Rawat
67183ad90c Merge pull request #53995 from khushi8112/fix-depreciation-cancel-test
fix(test): pin posting date in test_depreciation_on_cancel_invoice
2026-04-03 00:58:03 +05:30
khushi8112
7f72189665 fix(test): pin posting date in test_depreciation_on_cancel_invoice 2026-04-03 00:33:17 +05:30
Smit Vora
3c327d5225 fix(ux): refresh grid to correctly persist the state of fields 2026-04-01 08:58:00 +05:30
Rohit Waghchaure
62d58702a0 fix: not able to set operation in work order 2026-04-01 08:57:40 +05:30
Rohit Waghchaure
1d36cb55cd feat: Allow Editing of Items and Quantities in Work Order 2026-04-01 08:55:09 +05:30
mergify[bot]
3fbfad1b9b fix: include rejected qty in tax (purchase receipt) (backport #53624) (#53971)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: include rejected qty in tax (purchase receipt) (#53624)
2026-03-31 16:15:42 +00:00
Frappe PR Bot
2597eaad51 chore(release): Bumped to Version 15.103.1
## [15.103.1](https://github.com/frappe/erpnext/compare/v15.103.0...v15.103.1) (2026-03-31)

### Bug Fixes

* trigger release ([39aaefc](39aaefc202))

### Reverts

* botched backport (backport [#53967](https://github.com/frappe/erpnext/issues/53967)) ([#53968](https://github.com/frappe/erpnext/issues/53968)) ([75344e9](75344e9e82)), closes [#53776](https://github.com/frappe/erpnext/issues/53776) [#53766](https://github.com/frappe/erpnext/issues/53766) [#53767](https://github.com/frappe/erpnext/issues/53767)
2026-03-31 14:30:20 +00:00
Mihir Kandoi
39aaefc202 fix: trigger release 2026-03-31 19:58:36 +05:30
mergify[bot]
75344e9e82 revert: botched backport (backport #53967) (#53968)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix(manufacturing): apply work order status filter in job card (#53776)"
fix(manufacturing): apply work order status filter in job card (backport #53766) (#53767)"
2026-03-31 19:24:30 +05:30
Mihir Kandoi
22774fdf87 revert: botched backport (#53967)
fix(manufacturing): apply work order status filter in job card (#53776)"
fix(manufacturing): apply work order status filter in job card (backport #53766) (#53767)"
2026-03-31 13:52:02 +00:00
mergify[bot]
e159c79766 fix: do not show inv dimension unnecessarily in stock entry (backport #53946) (#53950)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: do not show inv dimension unnecessarily in stock entry (#53946)
2026-03-31 16:29:24 +05:30
mergify[bot]
94fe32f189 chore: remove inter warehouse transfer settings (backport #53860) (#53940)
* chore: remove inter warehouse transfer settings (#53860)

(cherry picked from commit 0696bd2082)

# Conflicts:
#	erpnext/stock/doctype/stock_settings/stock_settings.json

* chore: resolve conflicts

---------

Co-authored-by: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-03-31 06:08:02 +00:00
mergify[bot]
efdb004f0b fix(warehouse_capacity_dashboard): removed escape from template (backport #53907) (#53908)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
fix(warehouse_capacity_dashboard): removed `escape` from template (#53907)
2026-03-30 23:33:06 +05:30
130 changed files with 3507 additions and 1210 deletions

View File

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

View File

@@ -82,13 +82,15 @@ class AccountingDimension(Document):
else:
frappe.throw(_("Company {0} is added more than once").format(frappe.bold(default.company)))
def after_insert(self):
def on_update(self):
if frappe.flags.in_test:
make_dimension_in_accounting_doctypes(doc=self)
else:
frappe.enqueue(
make_dimension_in_accounting_doctypes, doc=self, queue="long", enqueue_after_commit=True
)
frappe.flags.accounting_dimensions = None
frappe.flags.accounting_dimensions_details = None
def on_trash(self):
if frappe.flags.in_test:
@@ -103,10 +105,6 @@ class AccountingDimension(Document):
if not self.fieldname:
self.fieldname = scrub(self.label)
def on_update(self):
frappe.flags.accounting_dimensions = None
frappe.flags.accounting_dimensions_details = None
def make_dimension_in_accounting_doctypes(doc, doclist=None):
if not doclist:

View File

@@ -398,7 +398,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
frappe.get_doc(
{
"doctype": "Customer",
"customer_group": "All Customer Groups",
"customer_group": "Individual",
"customer_type": "Company",
"customer_name": "Poore Simon's",
}
@@ -429,7 +429,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
frappe.get_doc(
{
"doctype": "Customer",
"customer_group": "All Customer Groups",
"customer_group": "Individual",
"customer_type": "Company",
"customer_name": "Fayva",
}

View File

@@ -209,7 +209,7 @@ def make_customer(customer=None):
{
"doctype": "Customer",
"customer_name": customer_name,
"customer_group": "All Customer Groups",
"customer_group": "Individual",
"customer_type": "Company",
"territory": "All Territories",
}

View File

@@ -2306,22 +2306,20 @@ def get_outstanding_reference_documents(args, validate=False):
# Get positive outstanding sales /purchase invoices
condition = ""
if args.get("voucher_type") and args.get("voucher_no"):
condition = " and voucher_type={} and voucher_no={}".format(
frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"])
)
condition = f" and voucher_type={frappe.db.escape(args['voucher_type'])} and voucher_no={frappe.db.escape(args['voucher_no'])}"
common_filter.append(ple.voucher_type == args["voucher_type"])
common_filter.append(ple.voucher_no == args["voucher_no"])
# Add cost center condition
if args.get("cost_center"):
condition += " and cost_center='%s'" % args.get("cost_center")
condition += f" and cost_center={frappe.db.escape(args.get('cost_center'))}"
accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center"))
# dynamic dimension filters
active_dimensions = get_dimensions()[0]
for dim in active_dimensions:
if args.get(dim.fieldname):
condition += f" and {dim.fieldname}='{args.get(dim.fieldname)}'"
condition += f" and {dim.fieldname}={frappe.db.escape(args.get(dim.fieldname))}"
accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname))
date_fields_dict = {
@@ -2331,17 +2329,15 @@ def get_outstanding_reference_documents(args, validate=False):
for fieldname, date_fields in date_fields_dict.items():
if args.get(date_fields[0]) and args.get(date_fields[1]):
condition += " and {} between '{}' and '{}'".format(
fieldname, args.get(date_fields[0]), args.get(date_fields[1])
)
condition += f" and {fieldname} between {frappe.db.escape(args.get(date_fields[0]))} and {frappe.db.escape(args.get(date_fields[1]))}"
posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
elif args.get(date_fields[0]):
# if only from date is supplied
condition += f" and {fieldname} >= '{args.get(date_fields[0])}'"
condition += f" and {fieldname} >= {frappe.db.escape(args.get(date_fields[0]))}"
posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0])))
elif args.get(date_fields[1]):
# if only to date is supplied
condition += f" and {fieldname} <= '{args.get(date_fields[1])}'"
condition += f" and {fieldname} <= {frappe.db.escape(args.get(date_fields[1]))}"
posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1])))
if args.get("company"):
@@ -2561,7 +2557,7 @@ def get_orders_to_be_billed(
active_dimensions = get_dimensions(True)[0]
for dim in active_dimensions:
if filters.get(dim.fieldname):
condition += f" and {dim.fieldname}='{filters.get(dim.fieldname)}'"
condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}"
if party_account_currency == company_currency:
grand_total_field = "base_grand_total"

View File

@@ -200,6 +200,30 @@ class TestPaymentEntry(FrappeTestCase):
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 100)
def test_reference_outstanding_amount_on_advance_pull(self):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
so = make_sales_order(qty=1, rate=1000)
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
pe.paid_amount = pe.received_amount = 500
pe.references[0].allocated_amount = 500
pe.insert()
pe.submit()
so.reload()
self.assertEqual(so.advance_paid, 500)
si = make_sales_invoice(so.name)
si.allocate_advances_automatically = 1
si.save()
self.assertEqual(si.get("advances")[0].allocated_amount, 500)
self.assertEqual(si.get("advances")[0].reference_name, pe.name)
si.submit()
pe.load_from_db()
self.assertEqual(pe.references[0].reference_name, si.name)
self.assertEqual(pe.references[0].outstanding_amount, si.outstanding_amount)
def test_payment_entry_against_pi(self):
pi = make_purchase_invoice(
supplier="_Test Supplier USD",
@@ -1937,6 +1961,37 @@ class TestPaymentEntry(FrappeTestCase):
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, pe.doctype, pe.name)
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, "Journal Entry", jv[0])
def test_project_name_in_exchange_gain_loss_entry(self):
si = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=50,
do_not_submit=True,
)
from erpnext.projects.doctype.project.test_project import make_project
si.project = make_project({"project_name": "_Test Project for Exchange Gain Loss Entry"}).name
si.submit()
pe = get_payment_entry("Sales Invoice", si.name)
pe.source_exchange_rate = 100
pe.insert()
pe.submit()
rows = frappe.get_all(
"Journal Entry Account",
or_filters=[{"reference_name": pe.name}, {"reference_name": si.name}],
fields=["project"],
)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0].project, si.project)
self.assertEqual(rows[1].project, si.project)
def create_payment_entry(**args):
payment_entry = frappe.new_doc("Payment Entry")
@@ -2043,6 +2098,7 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
customer.customer_name = name
customer.default_currency = currency
customer.type = "Individual"
customer.customer_group = "Individual"
customer.save()
customer = customer.name
return customer

View File

@@ -80,6 +80,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
customer.customer_group = "Individual"
customer.save()
self.customer = customer.name

View File

@@ -2546,6 +2546,7 @@ def make_customer(customer_name, currency=None):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.type = "Individual"
customer.customer_group = "Individual"
if currency:
customer.default_currency = currency

View File

@@ -46,8 +46,8 @@ frappe.ui.form.on("Period Closing Voucher", {
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,

View File

@@ -812,6 +812,7 @@
},
{
"default": "0",
"fetch_from": "item_code.grant_commission",
"fieldname": "grant_commission",
"fieldtype": "Check",
"label": "Grant Commission",
@@ -858,7 +859,7 @@
],
"istable": 1,
"links": [],
"modified": "2024-05-07 15:56:54.343317",
"modified": "2026-04-20 16:16:12.322024",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",

View File

@@ -658,7 +658,7 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None):
if pricing_rule.is_recursive:
transaction_qty = sum(
[
row.qty
flt(row.qty)
for row in doc.items
if not row.is_free_item
and row.item_code == args.item_code

View File

@@ -21,10 +21,12 @@ frappe.ui.form.on("Promotional Scheme", {
selling: function (frm) {
frm.trigger("set_options_for_applicable_for");
frm.toggle_enable("buying", !frm.doc.selling);
},
buying: function (frm) {
frm.trigger("set_options_for_applicable_for");
frm.toggle_enable("selling", !frm.doc.buying);
},
set_options_for_applicable_for: function (frm) {

View File

@@ -978,6 +978,10 @@ class PurchaseInvoice(BuyingController):
if provisional_accounting_for_non_stock_items:
self.get_provisional_accounts()
adjust_incoming_rate = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
for item in self.get("items"):
if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate):
if item.item_code:
@@ -1146,7 +1150,11 @@ class PurchaseInvoice(BuyingController):
)
# check if the exchange rate has changed
if item.get("purchase_receipt") and self.auto_accounting_for_stock:
if (
not adjust_incoming_rate
and item.get("purchase_receipt")
and self.auto_accounting_for_stock
):
if (
exchange_rate_map[item.purchase_receipt]
and self.conversion_rate != exchange_rate_map[item.purchase_receipt]
@@ -1183,6 +1191,7 @@ class PurchaseInvoice(BuyingController):
item=item,
)
)
if (
self.auto_accounting_for_stock
and self.is_opening == "No"

View File

@@ -356,6 +356,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
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)
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
@@ -376,12 +382,17 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
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
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as create_purchase_invoice,

View File

@@ -731,7 +731,6 @@
"label": "Valuation Rate",
"no_copy": 1,
"options": "Company:company:default_currency",
"precision": "6",
"print_hide": 1,
"read_only": 1
},
@@ -984,7 +983,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-10-14 13:01:54.441511",
"modified": "2026-04-07 15:41:45.687554",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -166,13 +166,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
);
}
}
// Show buttons only when pos view is active
if (cint(doc.docstatus == 0) && cur_frm.page.current_view_name !== "pos" && !doc.is_return) {
this.frm.cscript.sales_order_btn();
this.frm.cscript.delivery_note_btn();
this.frm.cscript.quotation_btn();
}
this.toggle_get_items();
this.set_default_print_format();
if (doc.docstatus == 1 && !doc.inter_company_invoice_reference) {
@@ -258,6 +252,93 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
}
}
toggle_get_items() {
const buttons = ["Sales Order", "Quotation", "Timesheet", "Delivery Note"];
buttons.forEach((label) => {
this.frm.remove_custom_button(label, "Get Items From");
});
if (cint(this.frm.doc.docstatus) !== 0 || this.frm.page.current_view_name === "pos") {
return;
}
if (!this.frm.doc.is_return) {
this.frm.cscript.sales_order_btn();
this.frm.cscript.quotation_btn();
this.frm.cscript.timesheet_btn();
}
this.frm.cscript.delivery_note_btn();
}
timesheet_btn() {
var me = this;
me.frm.add_custom_button(
__("Timesheet"),
function () {
let d = new frappe.ui.Dialog({
title: __("Fetch Timesheet"),
fields: [
{
label: __("From"),
fieldname: "from_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Item Code"),
fieldname: "item_code",
fieldtype: "Link",
options: "Item",
get_query: () => {
return {
query: "erpnext.controllers.queries.item_query",
filters: {
is_sales_item: 1,
customer: me.frm.doc.customer,
has_variants: 0,
},
};
},
},
{
fieldtype: "Column Break",
fieldname: "col_break_1",
},
{
label: __("To"),
fieldname: "to_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Project"),
fieldname: "project",
fieldtype: "Link",
options: "Project",
default: me.frm.doc.project,
},
],
primary_action: function () {
const data = d.get_values();
me.frm.events.add_timesheet_data(me.frm, {
from_time: data.from_time,
to_time: data.to_time,
project: data.project,
item_code: data.item_code,
});
d.hide();
},
primary_action_label: __("Get Timesheets"),
});
d.show();
},
__("Get Items From")
);
}
sales_order_btn() {
var me = this;
this.$sales_order_btn = this.frm.add_custom_button(
@@ -322,6 +403,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
this.$delivery_note_btn = this.frm.add_custom_button(
__("Delivery Note"),
function () {
if (!me.frm.doc.customer) {
frappe.throw({
title: __("Mandatory"),
message: __("Please Select a Customer"),
});
}
erpnext.utils.map_current_doc({
method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
source_doctype: "Delivery Note",
@@ -334,7 +421,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
var filters = {
docstatus: 1,
company: me.frm.doc.company,
is_return: 0,
is_return: me.frm.doc.is_return,
};
if (me.frm.doc.customer) filters["customer"] = me.frm.doc.customer;
return {
@@ -594,6 +681,14 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
this.calculate_taxes_and_totals();
}
apply_tds(frm) {
this.frm.clear_table("tax_withholding_entries");
}
is_return() {
this.toggle_get_items();
}
};
// for backward compatibility: combine new and previous states
@@ -1039,71 +1134,6 @@ frappe.ui.form.on("Sales Invoice", {
},
refresh: function (frm) {
if (frm.doc.docstatus === 0 && !frm.doc.is_return) {
frm.add_custom_button(
__("Timesheet"),
function () {
let d = new frappe.ui.Dialog({
title: __("Fetch Timesheet"),
fields: [
{
label: __("From"),
fieldname: "from_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Item Code"),
fieldname: "item_code",
fieldtype: "Link",
options: "Item",
get_query: () => {
return {
query: "erpnext.controllers.queries.item_query",
filters: {
is_sales_item: 1,
customer: frm.doc.customer,
has_variants: 0,
},
};
},
},
{
fieldtype: "Column Break",
fieldname: "col_break_1",
},
{
label: __("To"),
fieldname: "to_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Project"),
fieldname: "project",
fieldtype: "Link",
options: "Project",
default: frm.doc.project,
},
],
primary_action: function () {
const data = d.get_values();
frm.events.add_timesheet_data(frm, {
from_time: data.from_time,
to_time: data.to_time,
project: data.project,
item_code: data.item_code,
});
d.hide();
},
primary_action_label: __("Get Timesheets"),
});
d.show();
},
__("Get Items From")
);
}
if (frm.doc.is_debit_note) {
frm.set_df_property("return_against", "label", __("Adjustment Against"));
}

View File

@@ -777,8 +777,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.total_billing_amount > 0",
"depends_on": "eval:!doc.is_return",
"collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
"fieldname": "time_sheet_list",
"fieldtype": "Section Break",
"hide_border": 1,
@@ -792,7 +791,6 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Time Sheets",
"no_copy": 1,
"options": "Sales Invoice Timesheet",
"print_hide": 1
},
@@ -2112,7 +2110,7 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)",
"depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
"fieldname": "section_break_104",
"fieldtype": "Section Break"
},
@@ -2200,7 +2198,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-02-05 20:43:44.732805",
"modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -323,10 +323,22 @@ class SalesInvoice(SellingController):
)
self.set_against_income_account()
self.validate_time_sheets_are_submitted()
if self.is_return and not self.return_against and self.timesheets:
frappe.throw(_("Direct return is not allowed for Timesheet."))
if not self.is_return:
self.validate_time_sheets_are_submitted()
self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
if self.is_return:
self.timesheets = []
if self.is_return and self.return_against:
for row in self.timesheets:
if row.billing_hours:
row.billing_hours = -abs(row.billing_hours)
if row.billing_amount:
row.billing_amount = -abs(row.billing_amount)
self.update_packing_list()
self.set_billing_hours_and_amount()
self.update_timesheet_billing_for_project()
@@ -494,7 +506,7 @@ class SalesInvoice(SellingController):
if not cint(self.is_pos) == 1 and not self.is_return:
self.update_against_document_in_jv()
self.update_time_sheet(self.name)
self.update_time_sheet(None if (self.is_return and self.return_against) else self.name)
if frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
update_company_current_month_sales(self.company)
@@ -550,7 +562,7 @@ class SalesInvoice(SellingController):
self.check_if_consolidated_invoice()
super().before_cancel()
self.update_time_sheet(None)
self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None)
def on_cancel(self):
check_if_return_invoice_linked_with_payment_entry(self)
@@ -735,8 +747,20 @@ class SalesInvoice(SellingController):
for data in timesheet.time_logs:
if (
(self.project and args.timesheet_detail == data.name)
or (not self.project and not data.sales_invoice)
or (not sales_invoice and data.sales_invoice == self.name)
or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name)
or (
not sales_invoice
and data.sales_invoice == self.name
and args.timesheet_detail == data.name
)
or (
self.is_return
and self.return_against
and data.sales_invoice
and data.sales_invoice == self.return_against
and not sales_invoice
and args.timesheet_detail == data.name
)
):
data.sales_invoice = sales_invoice
@@ -776,11 +800,25 @@ class SalesInvoice(SellingController):
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
def validate_time_sheets_are_submitted(self):
# Note: This validation is skipped for return invoices
# to allow returns to reference already-billed timesheet details
for data in self.timesheets:
# Handle invoice duplication
if data.time_sheet and data.timesheet_detail:
if sales_invoice := frappe.db.get_value(
"Timesheet Detail", data.timesheet_detail, "sales_invoice"
):
frappe.throw(
_("Row {0}: Sales Invoice {1} is already created for {2}").format(
data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
)
)
if data.time_sheet:
status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
if status not in ["Submitted", "Payslip"]:
frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet))
if status not in ["Submitted", "Payslip", "Partially Billed"]:
frappe.throw(
_("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
)
def set_pos_fields(self, for_validate=False):
"""Set retail related fields from POS Profiles"""
@@ -1112,7 +1150,12 @@ class SalesInvoice(SellingController):
timesheet.billing_amount = ts_doc.total_billable_amount
def update_timesheet_billing_for_project(self):
if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled():
if (
not self.is_return
and not self.timesheets
and self.project
and self.is_auto_fetch_timesheet_enabled()
):
self.add_timesheet_data()
else:
self.calculate_billing_amount_for_timesheet()

View File

@@ -2917,7 +2917,7 @@ class TestSalesInvoice(FrappeTestCase):
si.submit()
# Check if adjustment entry is created
self.assertTrue(
self.assertFalse(
frappe.db.exists(
"GL Entry",
{
@@ -3230,7 +3230,7 @@ class TestSalesInvoice(FrappeTestCase):
calculate_depreciation=1,
submit=1,
)
post_depreciation_entries()
post_depreciation_entries(date="2025-04-01")
si = create_sales_invoice(
item_code="Macbook Pro", asset=asset.name, qty=1, rate=10000, posting_date=getdate("2025-05-01")

View File

@@ -52,7 +52,6 @@
"fieldtype": "Data",
"hidden": 1,
"label": "Timesheet Detail",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
@@ -117,7 +116,7 @@
],
"istable": 1,
"links": [],
"modified": "2021-10-02 03:48:44.979777",
"modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Timesheet",

View File

@@ -25,6 +25,10 @@ frappe.ui.form.on("Shipping Rule", {
},
calculate_based_on: function (frm) {
frm.trigger("toggle_reqd");
if (frm.doc.calculate_based_on === "Fixed") {
frm.clear_table("conditions");
frm.refresh_field("conditions");
}
},
toggle_reqd: function (frm) {
frm.toggle_reqd("shipping_amount", frm.doc.calculate_based_on === "Fixed");

View File

@@ -58,6 +58,11 @@ class ShippingRule(Document):
self.validate_overlapping_shipping_rule_conditions()
def validate_from_to_values(self):
if self.calculate_based_on == "Fixed":
if self.conditions:
self.set("conditions", [])
return
zero_to_values = []
for d in self.get("conditions"):

View File

@@ -629,18 +629,21 @@ def create_parties():
customer.customer_name = "_Test Subscription Customer"
customer.default_currency = "USD"
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"})
customer.customer_group = "Individual"
customer.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer Multi Currency"):
customer = frappe.new_doc("Customer")
customer.customer_name = "Test Subscription Customer Multi Currency"
customer.default_currency = "USD"
customer.customer_group = "Individual"
customer.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"):
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Subscription Customer John Doe"
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable - _TC"})
customer.customer_group = "Individual"
customer.insert()

View File

@@ -34,6 +34,17 @@ frappe.query_reports["Accounts Payable"] = {
},
options: "Cost Center",
},
{
fieldname: "project",
label: __("Project"),
fieldtype: "MultiSelectList",
options: "Project",
get_data: function (txt) {
return frappe.db.get_link_options("Project", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
},
{
fieldname: "party_account",
label: __("Payable Account"),

View File

@@ -120,3 +120,49 @@ class TestAccountsPayable(AccountsTestMixin, FrappeTestCase):
self.assertEqual(len(report[1]), 2)
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
def test_project_filter(self):
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
).insert()
pi = self.create_purchase_invoice(do_not_submit=True)
pi.project = project.name
pi.save().submit()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"project": [project.name],
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
row = report[0]
self.assertEqual(row.project, project.name)
self.assertEqual(row.invoiced, 300.0)
def test_project_on_report_output(self):
"""
Report row must carry the invoice's project.
"""
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
}
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company}
).insert()
pi = self.create_purchase_invoice(do_not_submit=True)
pi.project = project.name
pi.save().submit()
report = execute(filters)
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual([pi.name, project.name, 300], [row.voucher_no, row.project, row.outstanding])

View File

@@ -53,6 +53,17 @@ frappe.query_reports["Accounts Payable Summary"] = {
},
options: "Cost Center",
},
{
fieldname: "project",
label: __("Project"),
fieldtype: "MultiSelectList",
options: "Project",
get_data: function (txt) {
return frappe.db.get_link_options("Project", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
},
{
fieldname: "party_type",
label: __("Party Type"),

View File

@@ -36,6 +36,17 @@ frappe.query_reports["Accounts Receivable"] = {
},
options: "Cost Center",
},
{
fieldname: "project",
label: __("Project"),
fieldtype: "MultiSelectList",
options: "Project",
get_data: function (txt) {
return frappe.db.get_link_options("Project", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
},
{
fieldname: "party_type",
label: __("Party Type"),

View File

@@ -194,6 +194,7 @@ class ReceivablePayableReport:
and ple.against_voucher_type in self.advance_payment_doctypes
):
self.voucher_balance[key].cost_center = ple.cost_center
self.voucher_balance[key].project = ple.project
self.get_invoices(ple)
@@ -360,6 +361,7 @@ class ReceivablePayableReport:
posting_date,
account_currency,
cost_center,
project,
sum(invoiced) `invoiced`,
sum(paid) `paid`,
sum(credit_note) `credit_note`,
@@ -388,6 +390,7 @@ class ReceivablePayableReport:
"credit_note_in_account_currency",
"outstanding_in_account_currency",
"cost_center",
"project",
]:
_d[field] = x.get(field)
@@ -925,6 +928,7 @@ class ReceivablePayableReport:
ple.against_voucher_no,
ple.party_type,
ple.cost_center,
ple.project,
ple.party,
ple.posting_date,
ple.due_date,
@@ -992,6 +996,9 @@ class ReceivablePayableReport:
if self.filters.cost_center:
self.get_cost_center_conditions()
if self.filters.project:
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
self.add_accounting_dimensions_filters()
def get_cost_center_conditions(self):
@@ -1231,6 +1238,7 @@ class ReceivablePayableReport:
)
self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data")
self.add_column(label=_("Project"), fieldname="project", fieldtype="Link", options="Project")
self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data")
self.add_column(
label=_("Voucher No"),
@@ -1403,6 +1411,7 @@ class InitSQLProceduresForAR:
posting_date date,
account_currency {_varchar_type},
cost_center {_varchar_type},
project {_varchar_type},
invoiced {_currency_type},
paid {_currency_type},
credit_note {_currency_type},
@@ -1422,6 +1431,7 @@ class InitSQLProceduresForAR:
against_voucher_no {_varchar_type},
party_type {_varchar_type},
cost_center {_varchar_type},
project {_varchar_type},
party {_varchar_type},
posting_date date,
due_date date,
@@ -1450,7 +1460,7 @@ class InitSQLProceduresForAR:
begin
if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false))
then
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0);
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, ple.project, 0, 0, 0, 0, 0, 0);
end if;
end;
"""
@@ -1492,7 +1502,7 @@ class InitSQLProceduresForAR:
end if;
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', '', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
end;
"""

View File

@@ -779,6 +779,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"customer_name": "Jane Doe",
"type": "Individual",
"default_currency": "USD",
"customer_group": "Individual",
}
)
.insert()
@@ -1002,6 +1003,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"customer_name": "Jane Doe",
"type": "Individual",
"default_currency": "USD",
"customer_group": "Individual",
}
)
.insert()
@@ -1202,3 +1204,52 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
self.assertEqual(len(report[1]), 2)
self.assertEqual([si.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
def test_project_filter(self):
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AR Project", "company": self.company}
).insert()
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.project = project.name
si.save().submit()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"project": [project.name],
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
row = report[0]
self.assertEqual(row.project, project.name)
self.assertEqual(row.invoiced, 100.0)
def test_project_on_report_output(self):
"""
Report row must carry the invoice's project even when the payment entry
has no project set.
"""
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
}
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AR Project Output", "company": self.company}
).insert()
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.project = project.name
si.save().submit()
# payment has no project — report row must still show the invoice's project
self.create_payment_entry(si.name)
report = execute(filters)
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])

View File

@@ -53,6 +53,17 @@ frappe.query_reports["Accounts Receivable Summary"] = {
},
options: "Cost Center",
},
{
fieldname: "project",
label: __("Project"),
fieldtype: "MultiSelectList",
options: "Project",
get_data: function (txt) {
return frappe.db.get_link_options("Project", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
},
{
fieldname: "party_type",
label: __("Party Type"),

View File

@@ -82,6 +82,7 @@ class TestGrossProfit(FrappeTestCase):
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
customer.customer_group = "Individual"
customer.save()
self.customer = customer.name

View File

@@ -501,7 +501,7 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc
else sum(base_tax_amount_after_discount_amount) * -1 end as tax_amount
from `tabPurchase Taxes and Charges`
where parent in (%s) and category in ('Total', 'Valuation and Total')
and base_tax_amount_after_discount_amount != 0
and base_tax_amount_after_discount_amount != 0 and parenttype='Purchase Invoice'
group by parent, account_head, add_deduct_tax
"""
% ", ".join(["%s"] * len(invoice_list)),

View File

@@ -6,6 +6,7 @@ from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_months, today
from erpnext.accounts.report.purchase_register.purchase_register import execute
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
class TestPurchaseRegister(FrappeTestCase):
@@ -26,6 +27,52 @@ class TestPurchaseRegister(FrappeTestCase):
self.assertEqual(first_row.total_tax, 100)
self.assertEqual(first_row.grand_total, 1100)
def test_purchase_register_ignores_tax_rows_from_other_doctype(self):
frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'")
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'")
filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today())
pi = make_purchase_invoice()
# Real workflow setup: create a Purchase Receipt tax row in the same shared child table.
pr = make_purchase_receipt(
company="_Test Company 6",
supplier="_Test Supplier",
item="_Test Item",
warehouse="_Test Warehouse - _TC6",
cost_center="_Test Cost Center - _TC6",
do_not_save=1,
do_not_submit=1,
qty=1,
rate=1000,
)
pr.append(
"taxes",
{
"account_head": "GST - _TC6",
"cost_center": "_Test Cost Center - _TC6",
"add_deduct_tax": "Add",
"category": "Valuation and Total",
"charge_type": "Actual",
"description": "PR Tax",
"tax_amount": 100.0,
"rate": 100,
},
)
pr.insert()
pr.submit()
# Mimic custom naming collision across doctypes (same parent value in shared child table).
frappe.rename_doc("Purchase Receipt", pr.name, pi.name, force=True)
report_results = execute(filters)
first_row = frappe._dict(report_results[1][0])
self.assertEqual(first_row.voucher_no, pi.name)
self.assertEqual(first_row.total_tax, 100)
self.assertEqual(first_row.grand_total, 1100)
def test_purchase_register_ledger_view(self):
frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'")
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'")

View File

@@ -5,6 +5,7 @@ from frappe.utils import getdate, today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_register.sales_register import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
@@ -75,6 +76,43 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
report_output = {k: v for k, v in res[0].items() if k in expected_result}
self.assertDictEqual(report_output, expected_result)
def test_sales_register_ignores_tax_rows_from_other_doctype(self):
si = self.create_sales_invoice(rate=98)
# Real workflow setup: create a Sales Order with taxes in the shared child table.
so = make_sales_order(
item=self.item,
company=self.company,
customer=self.customer,
rate=77,
do_not_save=1,
do_not_submit=1,
)
so.append(
"taxes",
{
"charge_type": "Actual",
"account_head": self.income_account,
"description": "SO Tax",
"tax_amount": 55.0,
},
)
so.insert()
so.submit()
# Mimic custom naming collision across doctypes (same parent value in shared child table).
frappe.rename_doc("Sales Order", so.name, si.name, force=True)
filters = frappe._dict({"from_date": today(), "to_date": today(), "company": self.company})
report = execute(filters)
res = [x for x in report[1] if x.get("voucher_no") == si.name]
self.assertEqual(len(res), 1)
result = frappe._dict(res[0])
self.assertEqual(result.net_total, 98.0)
self.assertEqual(result.tax_total, 0)
self.assertEqual(result.grand_total, 98.0)
def test_journal_with_cost_center_filter(self):
je1 = frappe.get_doc(
{

View File

@@ -119,8 +119,8 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map):
row.update(
{
"section_code": tax_withholding_category or "",
"entity_type": party_map.get(party, {}).get(party_type),
"tax_withholding_category": tax_withholding_category or "",
"party_entity_type": party_map.get(party, {}).get(party_type),
"rate": rate,
"total_amount": total_amount,
"grand_total": grand_total,
@@ -141,7 +141,7 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map):
else:
entries[key] = row
out = list(entries.values())
out.sort(key=lambda x: (x["section_code"], x["transaction_date"], x["ref_no"]))
out.sort(key=lambda x: (x["tax_withholding_category"], x["transaction_date"], x["ref_no"]))
return out
@@ -205,9 +205,9 @@ def get_columns(filters):
pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id"
columns = [
{
"label": _("Section Code"),
"label": _("Tax Withholding Category"),
"options": "Tax Withholding Category",
"fieldname": "section_code",
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
"width": 90,
},
@@ -236,7 +236,12 @@ def get_columns(filters):
columns.extend(
[
{"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 100},
{
"label": _(f"{filters.get('party_type', 'Party')} Type"),
"fieldname": "party_entity_type",
"fieldtype": "Data",
"width": 100,
},
]
)
if filters.party_type == "Supplier":

View File

@@ -118,7 +118,7 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
voucher_expected_values = expected_values[i]
voucher_actual_values = (
voucher.ref_no,
voucher.section_code,
voucher.tax_withholding_category,
voucher.rate,
voucher.base_tax_withholding_net_total,
voucher.base_total,

View File

@@ -48,28 +48,25 @@ def group_by_party_and_category(data, filters):
party_category_wise_map = {}
for row in data:
key = (row.get("party_type"), row.get("party"), row.get("tax_withholding_category"))
party_category_wise_map.setdefault(
(row.get("party"), row.get("section_code")),
key,
{
"pan": row.get("pan"),
"tax_id": row.get("tax_id"),
"party": row.get("party"),
"party_type": row.get("party_type"),
"party_name": row.get("party_name"),
"section_code": row.get("section_code"),
"entity_type": row.get("entity_type"),
"tax_withholding_category": row.get("tax_withholding_category"),
"party_entity_type": row.get("party_entity_type"),
"rate": row.get("rate"),
"total_amount": 0.0,
"tax_amount": 0.0,
},
)
party_category_wise_map.get((row.get("party"), row.get("section_code")))["total_amount"] += row.get(
"total_amount", 0.0
)
party_category_wise_map.get((row.get("party"), row.get("section_code")))["tax_amount"] += row.get(
"tax_amount", 0.0
)
party_category_wise_map.get(key)["total_amount"] += row.get("total_amount", 0.0)
party_category_wise_map.get(key)["tax_amount"] += row.get("tax_amount", 0.0)
final_result = get_final_result(party_category_wise_map)
@@ -110,13 +107,18 @@ def get_columns(filters):
columns.extend(
[
{
"label": _("Section Code"),
"label": _("Tax Withholding Category"),
"options": "Tax Withholding Category",
"fieldname": "section_code",
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
"width": 180,
},
{"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 180},
{
"label": _(f"{filters.get('party_type', 'Party')} Type"),
"fieldname": "party_entity_type",
"fieldtype": "Data",
"width": 180,
},
{
"label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"),
"fieldname": "rate",

View File

@@ -12,6 +12,7 @@ class AccountsTestMixin:
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.type = "Individual"
customer.customer_group = "Individual"
if currency:
customer.default_currency = currency
@@ -36,6 +37,7 @@ class AccountsTestMixin:
"account": default_account,
},
)
customer.customer_group = "Individual"
customer.save()
self.customer = customer_name

View File

@@ -7,12 +7,8 @@ from erpnext.accounts.party import get_default_price_list
class PartyTestCase(FrappeTestCase):
def test_get_default_price_list_should_return_none_for_invalid_group(self):
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "test customer",
}
{"doctype": "Customer", "customer_name": "test customer", "customer_group": "Individual"}
).insert(ignore_permissions=True, ignore_mandatory=True)
customer.customer_group = None
customer.save()
price_list = get_default_price_list(customer)
assert price_list is None

View File

@@ -500,7 +500,7 @@ def reconcile_against_document(
skip_ref_details_update_for_pe=skip_ref_details_update_for_pe,
dimensions_dict=dimensions_dict,
)
if referenced_row.get("outstanding_amount"):
if referenced_row.get("outstanding_amount") and entry.get("outstanding_amount") is None:
referenced_row.outstanding_amount -= flt(entry.allocated_amount)
reposting_rows.append(referenced_row)
@@ -2320,6 +2320,7 @@ def create_gain_loss_journal(
ref2_detail_no,
cost_center,
dimensions,
project=None,
) -> str:
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Exchange Gain Or Loss"
@@ -2346,6 +2347,7 @@ def create_gain_loss_journal(
"account_currency": party_account_currency,
"exchange_rate": 0,
"cost_center": cost_center or erpnext.get_default_cost_center(company),
"project": project,
"reference_type": ref1_dt,
"reference_name": ref1_dn,
"reference_detail_no": ref1_detail_no,
@@ -2363,6 +2365,7 @@ def create_gain_loss_journal(
"account_currency": gain_loss_account_currency,
"exchange_rate": 1,
"cost_center": cost_center or erpnext.get_default_cost_center(company),
"project": project,
"reference_type": ref2_dt,
"reference_name": ref2_dn,
"reference_detail_no": ref2_detail_no,

View File

@@ -41,7 +41,7 @@ frappe.ui.form.on("Asset Movement", {
});
},
onload: (frm) => {
refresh: (frm) => {
frm.trigger("set_required_fields");
},

View File

@@ -289,6 +289,30 @@ class TestPurchaseOrder(FrappeTestCase):
# ordered qty should decrease (back to initial) on row deletion
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
def test_discount_amount_partial_purchase_receipt(self):
po = create_purchase_order(qty=4, rate=100, do_not_save=1)
po.apply_discount_on = "Grand Total"
po.discount_amount = 120
po.save()
po.submit()
self.assertEqual(po.grand_total, 280)
pr1 = make_purchase_receipt(po.name)
pr1.items[0].qty = 3
pr1.save()
pr1.submit()
self.assertEqual(pr1.discount_amount, 120)
self.assertEqual(pr1.grand_total, 180)
pr2 = make_purchase_receipt(po.name)
pr2.save()
pr2.submit()
self.assertEqual(pr2.discount_amount, 0)
self.assertEqual(pr2.grand_total, 100)
def test_update_child_perm(self):
po = create_purchase_order(item_code="_Test Item", qty=4)

View File

@@ -283,7 +283,7 @@ class RequestforQuotation(BuyingController):
}
)
user.save(ignore_permissions=True)
update_password_link = user.reset_password()
update_password_link = user._reset_password()
return user, update_password_link
@@ -474,6 +474,11 @@ def create_supplier_quotation(doc):
if isinstance(doc, str):
doc = json.loads(doc)
if frappe.session.user not in frappe.get_all(
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
):
frappe.throw(_("Not Permitted"), frappe.PermissionError)
try:
sq_doc = frappe.get_doc(
{

View File

@@ -263,6 +263,13 @@ def make_request_for_quotation(**args) -> "RequestforQuotation":
for data in supplier_data:
rfq.append("suppliers", data)
frappe.new_doc(
"Portal User",
user="Administrator",
parent=data.get("supplier"),
parentfield="portal_users",
parenttype="Supplier",
).insert()
rfq.append(
"items",

View File

@@ -175,6 +175,15 @@ def create_supplier(**args):
if not args.without_supplier_group:
doc.supplier_group = args.supplier_group or "Services"
if args.get("party_account"):
doc.append(
"accounts",
{
"company": frappe.db.get_value("Account", args.get("party_account"), "company"),
"account": args.get("party_account"),
},
)
doc.insert()
return doc

View File

@@ -1751,6 +1751,7 @@ class AccountsController(TransactionBase):
arg.get("referenced_row"),
arg.get("cost_center"),
dimensions_dict,
arg.get("project"),
)
frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format(
@@ -1835,6 +1836,7 @@ class AccountsController(TransactionBase):
d.idx,
self.cost_center,
dimensions_dict,
self.project,
)
frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format(

View File

@@ -364,7 +364,17 @@ class BuyingController(SubcontractingController):
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
)
net_rate = item.base_net_amount
net_rate = (
flt(
(item.base_net_amount / item.received_qty) * item.qty,
item.precision("base_net_amount"),
)
if item.received_qty
and frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
else item.base_net_amount
)
if item.sales_incoming_rate: # for internal transfer
net_rate = item.qty * item.sales_incoming_rate

View File

@@ -356,38 +356,43 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, filters, as_dict):
doctype = "Delivery Note"
def get_delivery_notes_to_be_billed(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict, as_dict: bool = False
):
DeliveryNote = frappe.qb.DocType("Delivery Note")
fields = get_fields(doctype, ["name", "customer", "posting_date"])
return frappe.db.sql(
"""
select {fields}
from `tabDelivery Note`
where `tabDelivery Note`.`{key}` like {txt} and
`tabDelivery Note`.docstatus = 1
and status not in ('Stopped', 'Closed') {fcond}
and (
(`tabDelivery Note`.is_return = 0 and `tabDelivery Note`.per_billed < 100)
or (`tabDelivery Note`.grand_total = 0 and `tabDelivery Note`.per_billed < 100)
or (
`tabDelivery Note`.is_return = 1
and return_against in (select name from `tabDelivery Note` where per_billed < 100)
original_dn = (
frappe.qb.from_(DeliveryNote)
.select(DeliveryNote.name)
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
)
query = (
frappe.qb.from_(DeliveryNote)
.select(*[DeliveryNote[f] for f in fields])
.where(
(DeliveryNote.docstatus == 1)
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
& (DeliveryNote[searchfield].like(f"%{txt}%"))
& (
((DeliveryNote.is_return == 0) & (DeliveryNote.per_billed < 100))
| ((DeliveryNote.grand_total == 0) & (DeliveryNote.per_billed < 100))
| (
(DeliveryNote.is_return == 1)
& (DeliveryNote.per_billed < 100)
& (DeliveryNote.return_against.isin(original_dn))
)
)
{mcond} order by `tabDelivery Note`.`{key}` asc limit {page_len} offset {start}
""".format(
fields=", ".join([f"`tabDelivery Note`.{f}" for f in fields]),
key=searchfield,
fcond=get_filters_cond(doctype, filters, []),
mcond=get_match_cond(doctype),
start=start,
page_len=page_len,
txt="%(txt)s",
),
{"txt": ("%%%s%%" % txt)},
as_dict=as_dict,
)
)
if filters and isinstance(filters, dict):
for key, value in filters.items():
query = query.where(DeliveryNote[key] == value)
query = query.orderby(DeliveryNote[searchfield], order=Order.asc).limit(page_len).offset(start)
return query.run(as_dict=as_dict)
@frappe.whitelist()

View File

@@ -616,11 +616,11 @@ class SellingController(StockController):
if allow_at_arms_length_price:
continue
rate = flt(
flt(d.incoming_rate, d.precision("incoming_rate")) * d.conversion_factor,
d.precision("rate"),
)
if d.rate != rate:
rate = flt(flt(d.incoming_rate) * flt(d.conversion_factor or 1.0))
if flt(d.rate, d.precision("incoming_rate")) != flt(
rate, d.precision("incoming_rate")
):
d.rate = rate
frappe.msgprint(
_(

View File

@@ -183,8 +183,14 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
do_not_round_fields = ["valuation_rate", "incoming_rate"]
for item in self.doc.items:
self.doc.round_floats_in(item)
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
if item.discount_percentage == 100:
item.rate = 0.0
@@ -238,7 +244,13 @@ class calculate_taxes_and_totals:
elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount"))
else:
item.amount = flt(item.rate * item.qty, item.precision("amount"))
qty = (
(item.qty + item.rejected_qty)
if bill_for_rejected_quantity_in_purchase_invoice
and self.doc.doctype == "Purchase Receipt"
else item.qty
)
item.amount = flt(item.rate * qty, item.precision("amount"))
item.net_amount = item.amount
@@ -370,9 +382,16 @@ class calculate_taxes_and_totals:
self.doc.total
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
for item in self._items:
self.doc.total += item.amount
self.doc.total_qty += item.qty
self.doc.total_qty += (
(item.qty + item.rejected_qty)
if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt"
else item.qty
)
self.doc.base_total += item.base_amount
self.doc.net_total += item.net_amount
self.doc.base_net_total += item.base_net_amount
@@ -674,18 +693,17 @@ class calculate_taxes_and_totals:
if self.doc.meta.get_field("rounded_total"):
if self.doc.is_rounded_total_disabled():
self.doc.rounded_total = 0
self.doc.base_rounded_total = 0
self.doc.rounding_adjustment = 0
return
self.doc.rounded_total = round_based_on_smallest_currency_fraction(
self.doc.grand_total, self.doc.currency, self.doc.precision("rounded_total")
)
else:
self.doc.rounded_total = round_based_on_smallest_currency_fraction(
self.doc.grand_total, self.doc.currency, self.doc.precision("rounded_total")
)
# rounding adjustment should always be the difference vetween grand and rounded total
self.doc.rounding_adjustment = flt(
self.doc.rounded_total - self.doc.grand_total, self.doc.precision("rounding_adjustment")
)
# rounding adjustment should always be the difference between grand and rounded total
self.doc.rounding_adjustment = flt(
self.doc.rounded_total - self.doc.grand_total, self.doc.precision("rounding_adjustment")
)
self._set_in_company_currency(self.doc, ["rounding_adjustment", "rounded_total"])
@@ -724,7 +742,8 @@ class calculate_taxes_and_totals:
discount_amount += total_return_discount
# validate that discount amount cannot exceed the total before discount
if (
# only during save (i.e. when `_action` is set)
if self.doc.get("_action") and (
(grand_total >= 0 and discount_amount > grand_total)
or (grand_total < 0 and discount_amount < grand_total) # returns
):

View File

@@ -29,6 +29,7 @@ def make_customer(customer_name, currency=None):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Individual"
customer.customer_group = "Individual"
if currency:
customer.default_currency = currency

View File

@@ -66,7 +66,7 @@ class TestTaxes(unittest.TestCase):
{
"doctype": "Customer",
"customer_name": uuid4(),
"customer_group": "All Customer Groups",
"customer_group": "Individual",
}
).insert()
self.supplier = frappe.get_doc(

View File

@@ -0,0 +1,37 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
class TestTaxesAndTotals(FrappeTestCase):
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)
so.items[0].qty = 1
so.items[0].rate = 1000.25
so.items[0].price_list_rate = 1000.25
so.items[0].discount_percentage = 0
so.items[0].discount_amount = 0
so.set("taxes", [])
so.disable_rounded_total = 0
calculate_taxes_and_totals(so)
self.assertEqual(so.grand_total, 1000.25)
self.assertEqual(so.rounded_total, 1000.0)
self.assertEqual(so.rounding_adjustment, -0.25)
self.assertEqual(so.base_grand_total, 1000.25)
self.assertEqual(so.base_rounded_total, 1000.0)
self.assertEqual(so.base_rounding_adjustment, -0.25)
# User toggles disable_rounded_total after values are already set.
so.disable_rounded_total = 1
calculate_taxes_and_totals(so)
self.assertEqual(so.rounded_total, 0)
self.assertEqual(so.rounding_adjustment, 0)
self.assertEqual(so.base_rounded_total, 0)
self.assertEqual(so.base_rounding_adjustment, 0)

View File

@@ -4,7 +4,9 @@
import frappe
from frappe import _
from frappe.utils import getdate
from frappe.utils import DateTimeLikeObject, getdate, today
from erpnext.accounts.utils import get_fiscal_year
def get_columns(filters, trans):
@@ -45,6 +47,10 @@ def get_columns(filters, trans):
def validate_filters(filters):
if not filters.get("fiscal_year"):
filters["fiscal_year"] = get_fiscal_year(today())[0]
if not filters.get("company"):
filters["company"] = frappe.defaults.get_user_default("Company")
for f in ["Fiscal Year", "Based On", "Period", "Company"]:
if not filters.get(f.lower().replace(" ", "_")):
frappe.throw(_("{0} is mandatory").format(_(f)))

View File

@@ -35,7 +35,9 @@ class TestOpportunity(unittest.TestCase):
self.assertEqual(frappe.db.get_value("Lead", opp_doc.party_name, "email_id"), opp_doc.contact_email)
# create new customer and create new contact against 'new.opportunity@example.com'
customer = make_customer(opp_doc.party_name).insert(ignore_permissions=True)
customer = make_customer(opp_doc.party_name)
customer.customer_group = "Individual"
customer.insert(ignore_permissions=True)
contact = frappe.get_doc(
{
"doctype": "Contact",

View File

@@ -117,7 +117,7 @@ def get_join(filters):
join = """JOIN `tabOpportunity Lost Reason Detail`
ON `tabOpportunity Lost Reason Detail`.parenttype = 'Opportunity' and
`tabOpportunity Lost Reason Detail`.parent = `tabOpportunity`.name and
`tabOpportunity Lost Reason Detail`.lost_reason = '{}'
""".format(filters.get("lost_reason"))
`tabOpportunity Lost Reason Detail`.lost_reason=%(lost_reason)s
"""
return join

View File

@@ -196,6 +196,7 @@ def create_customer():
if not doc:
doc = frappe.new_doc("Customer")
doc.customer_name = "_Test NC"
doc.customer_group = "Individual"
doc.insert()

View File

@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
import frappe
from frappe.model.document import Document
from frappe.utils import escape_html
if TYPE_CHECKING:
from lxml.etree import Element
@@ -63,14 +64,16 @@ class CodeList(Document):
def from_genericode(self, root: "Element"):
"""Extract Code List details from genericode XML"""
self.title = root.find(".//Identification/ShortName").text
self.title = escape_html(root.find(".//Identification/ShortName").text)
self.version = root.find(".//Identification/Version").text
self.canonical_uri = root.find(".//CanonicalUri").text
# optionals
self.description = getattr(root.find(".//Identification/LongName"), "text", None)
self.publisher = getattr(root.find(".//Identification/Agency/ShortName"), "text", None)
self.description = escape_html(getattr(root.find(".//Identification/LongName"), "text", None))
self.publisher = escape_html(getattr(root.find(".//Identification/Agency/ShortName"), "text", None))
if not self.publisher:
self.publisher = getattr(root.find(".//Identification/Agency/LongName"), "text", None)
self.publisher = escape_html(
getattr(root.find(".//Identification/Agency/LongName"), "text", None)
)
self.publisher_id = getattr(root.find(".//Identification/Agency/Identifier"), "text", None)
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)

View File

@@ -10,6 +10,7 @@ erpnext.edi.import_genericode = function (listview_or_form) {
method: "erpnext.edi.doctype.code_list.code_list_import.import_genericode",
doctype: doctype,
docname: docname,
allow_web_link: false,
allow_toggle_private: false,
allow_take_photo: false,
on_success: function (_file_doc, r) {

View File

@@ -1,42 +1,118 @@
import json
from urllib.parse import urlsplit
import frappe
import requests
from frappe import _
from frappe.utils import escape_html
from frappe.utils.file_manager import save_file
from lxml import etree
URL_PREFIXES = ("http://", "https://")
GENERICODE_FETCH_TIMEOUT = 15
LOCAL_FILE_PREFIXES = ("/files/", "/private/files/")
class RemoteGenericodeUrlNotAllowedError(Exception):
pass
class CodeListSelectionMismatchError(Exception):
pass
@frappe.whitelist()
def import_genericode():
doctype = "Code List"
docname = frappe.form_dict.docname
content = frappe.local.uploaded_file
# recover the content, if it's a link
if (file_url := frappe.local.uploaded_file_url) and file_url.startswith(URL_PREFIXES):
try:
# If it's a URL, fetch the content and make it a local file (for durable audit)
response = requests.get(frappe.local.uploaded_file_url)
response.raise_for_status()
frappe.local.uploaded_file = content = response.content
frappe.local.uploaded_filename = frappe.local.uploaded_file_url.split("/")[-1]
frappe.local.uploaded_file_url = None
except Exception as e:
frappe.throw(f"<pre>{e!s}</pre>", title=_("Fetching Error"))
if file_url := frappe.local.uploaded_file_url:
file_path = frappe.utils.file_manager.get_file_path(file_url)
with open(file_path.encode(), mode="rb") as f:
content = f.read()
# Parse the xml content
parser = etree.XMLParser(remove_blank_text=True)
try:
root = etree.fromstring(content, parser=parser)
except Exception as e:
frappe.throw(f"<pre>{e!s}</pre>", title=_("Parsing Error"))
content, file_name = get_uploaded_genericode_file()
return import_genericode_content(
doctype="Code List",
docname=frappe.form_dict.docname,
content=content,
file_name=file_name,
)
except RemoteGenericodeUrlNotAllowedError:
frappe.throw(
_("Importing Code Lists from remote URLs is not allowed."),
title=_("Invalid Upload"),
)
except CodeListSelectionMismatchError:
frappe.throw(_("The uploaded file does not match the selected Code List."))
except etree.XMLSyntaxError:
frappe.throw(
_("The uploaded file could not be parsed as a genericode XML document."),
title=_("Parsing Error"),
)
def import_genericode_from_url(
url: str,
doctype: str = "Code List",
docname: str | None = None,
):
"""Import a Code List from a trusted backend URL."""
content = fetch_genericode_from_url(url)
file_name = urlsplit(url).path.rsplit("/", 1)[-1] or "genericode.xml"
return import_genericode_content(
doctype=doctype,
docname=docname,
content=content,
file_name=file_name,
)
def get_uploaded_genericode_file() -> tuple[bytes, str | None]:
uploaded_data = frappe.local.uploaded_file
file_name = frappe.local.uploaded_filename
if uploaded_data and file_name:
return uploaded_data, file_name
file_url = frappe.local.uploaded_file_url
if not file_url:
raise frappe.ValidationError(_("No file uploaded or URL provided."))
if not is_local_file_url(file_url):
raise RemoteGenericodeUrlNotAllowedError
file_doc = frappe.get_doc("File", {"file_url": file_url})
file_doc.check_permission("read")
return read_file_bytes(file_doc), file_name
def read_file_bytes(file_doc) -> bytes:
"""Return the raw bytes of a File document.
v15's `File.get_content` eagerly decodes to utf-8 and returns `str` for text
files, but `lxml.etree.fromstring` needs bytes when the XML declares an encoding.
"""
content = file_doc.get_content()
if isinstance(content, str):
content = content.encode("utf-8")
return content
def is_local_file_url(file_url: str | None) -> bool:
if not file_url:
return False
parsed = urlsplit(file_url.strip())
return not parsed.scheme and not parsed.netloc and parsed.path.startswith(LOCAL_FILE_PREFIXES)
def fetch_genericode_from_url(url: str) -> bytes:
response = requests.get(url, timeout=GENERICODE_FETCH_TIMEOUT)
response.raise_for_status()
return response.content
def import_genericode_content(
doctype: str,
docname: str | None,
content: bytes,
file_name: str | None,
):
root = parse_genericode_content(content)
# Extract the name (CanonicalVersionUri) from the parsed XML
name = root.find(".//CanonicalVersionUri").text
@@ -45,7 +121,7 @@ def import_genericode():
if frappe.db.exists(doctype, docname):
code_list = frappe.get_doc(doctype, docname)
if code_list.name != name:
frappe.throw(_("The uploaded file does not match the selected Code List."))
raise CodeListSelectionMismatchError
else:
# Create a new Code List document with the extracted name
code_list = frappe.new_doc(doctype)
@@ -54,19 +130,13 @@ def import_genericode():
code_list.from_genericode(root)
code_list.save()
# Attach the file and provide a recoverable identifier
file_doc = frappe.get_doc(
{
"doctype": "File",
"attached_to_doctype": "Code List",
"attached_to_name": code_list.name,
"folder": frappe.db.get_value("File", {"is_attachments_folder": 1}),
"file_name": frappe.local.uploaded_filename,
"file_url": frappe.local.uploaded_file_url,
"is_private": 1,
"content": content,
}
).save()
file_doc = save_file(
fname=file_name,
content=content,
dt=doctype,
dn=code_list.name,
is_private=1,
)
# Get available columns and example values
columns, example_values, filterable_columns = get_genericode_columns_and_examples(root)
@@ -81,6 +151,16 @@ def import_genericode():
}
def parse_genericode_content(content: bytes):
parser = etree.XMLParser(
remove_blank_text=True,
resolve_entities=False,
load_dtd=False,
no_network=True,
)
return etree.fromstring(content, parser=parser)
@frappe.whitelist()
def process_genericode_import(
code_list_name: str,
@@ -104,7 +184,7 @@ def get_genericode_columns_and_examples(root):
# Get column names
for column in root.findall(".//Column"):
column_id = column.get("Id")
column_id = escape_html(column.get("Id"))
columns.append(column_id)
example_values[column_id] = []
filterable_columns[column_id] = set()
@@ -112,7 +192,7 @@ def get_genericode_columns_and_examples(root):
# Get all values and count unique occurrences
for row in root.findall(".//SimpleCodeList/Row"):
for value in row.findall("Value"):
column_id = value.get("ColumnRef")
column_id = escape_html(value.get("ColumnRef"))
if column_id not in columns:
# Handle undeclared column
columns.append(column_id)
@@ -123,7 +203,7 @@ def get_genericode_columns_and_examples(root):
if simple_value is None:
continue
filterable_columns[column_id].add(simple_value.text)
filterable_columns[column_id].add(escape_html(simple_value.text))
# Get example values (up to 3) and filter columns with cardinality <= 5
for row in root.findall(".//SimpleCodeList/Row")[:3]:
@@ -133,7 +213,7 @@ def get_genericode_columns_and_examples(root):
if simple_value is None:
continue
example_values[column_id].append(simple_value.text)
example_values[column_id].append(escape_html(simple_value.text))
filterable_columns = {k: list(v) for k, v in filterable_columns.items() if len(v) <= 5}

View File

@@ -0,0 +1,200 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import Mock, patch
import frappe
import requests
from frappe.tests.utils import FrappeTestCase
from erpnext.edi.doctype.code_list import code_list_import
SAMPLE_GENERICODE = b"""<?xml version="1.0" encoding="UTF-8"?>
<CodeList>
<Identification>
<ShortName>Test Code List</ShortName>
<Version>1.0</Version>
<CanonicalUri>test-code-list</CanonicalUri>
<LongName>Code list for tests</LongName>
<Agency>
<ShortName>Test Agency</ShortName>
<Identifier>TEST</Identifier>
</Agency>
<LocationUri>https://example.com/codelists/test.xml</LocationUri>
</Identification>
<CanonicalVersionUri>test-code-list-v1</CanonicalVersionUri>
<ColumnSet>
<Column Id="code" />
<Column Id="name" />
<Column Id="category" />
</ColumnSet>
<SimpleCodeList>
<Row>
<Value ColumnRef="code"><SimpleValue>A</SimpleValue></Value>
<Value ColumnRef="name"><SimpleValue>Alpha</SimpleValue></Value>
<Value ColumnRef="category"><SimpleValue>Group 1</SimpleValue></Value>
</Row>
<Row>
<Value ColumnRef="code"><SimpleValue>B</SimpleValue></Value>
<Value ColumnRef="name"><SimpleValue>Beta</SimpleValue></Value>
<Value ColumnRef="category"><SimpleValue>Group 2</SimpleValue></Value>
</Row>
<Row>
<Value ColumnRef="code"><SimpleValue>C</SimpleValue></Value>
<Value ColumnRef="name"><SimpleValue>Gamma</SimpleValue></Value>
<Value ColumnRef="category"><SimpleValue>Group 1</SimpleValue></Value>
</Row>
</SimpleCodeList>
</CodeList>
"""
class TestCodeListImport(FrappeTestCase):
def test_import_genericode_rejects_remote_file_url(self):
self.set_upload_context(
file_name="trusted.xml",
file_url="https://example.com/codelists/trusted.xml",
)
with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get:
with self.assertRaisesRegex(
frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed."
):
code_list_import.import_genericode()
mock_get.assert_not_called()
def test_import_genericode_rejects_file_scheme_url(self):
self.set_upload_context(
file_name="trusted.xml",
file_url="file:///tmp/trusted.xml",
)
with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get:
with self.assertRaisesRegex(
frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed."
):
code_list_import.import_genericode()
mock_get.assert_not_called()
def test_import_genericode_from_trusted_url(self):
response = Mock()
response.content = SAMPLE_GENERICODE
response.raise_for_status.return_value = None
with patch(
"erpnext.edi.doctype.code_list.code_list_import.requests.get",
return_value=response,
) as mock_get:
import_result = code_list_import.import_genericode_from_url(
"https://example.com/codelists/trusted.xml"
)
self.assert_import_response(import_result)
mock_get.assert_called_once_with(
"https://example.com/codelists/trusted.xml",
timeout=code_list_import.GENERICODE_FETCH_TIMEOUT,
)
file_doc = frappe.get_doc("File", import_result["file"])
self.assertEqual(code_list_import.read_file_bytes(file_doc), SAMPLE_GENERICODE)
self.assertFalse(file_doc.file_url.startswith("https://"))
def test_import_genericode_from_trusted_url_propagates_fetch_errors(self):
with patch(
"erpnext.edi.doctype.code_list.code_list_import.requests.get",
side_effect=requests.Timeout,
):
with self.assertRaises(requests.Timeout):
code_list_import.import_genericode_from_url("https://example.com/codelists/trusted.xml")
def test_import_genericode_from_uploaded_file_returns_metadata(self):
self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml")
import_result = code_list_import.import_genericode()
self.assert_import_response(import_result)
file_doc = frappe.get_doc("File", import_result["file"])
self.assertEqual(code_list_import.read_file_bytes(file_doc), SAMPLE_GENERICODE)
def test_process_genericode_import_reads_file_doc_content(self):
self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml")
import_result = code_list_import.import_genericode()
count = code_list_import.process_genericode_import(
code_list_name=import_result["code_list"],
file_name=import_result["file"],
code_column="code",
title_column="name",
)
self.assertEqual(count, 3)
self.assertEqual(frappe.db.count("Common Code", {"code_list": import_result["code_list"]}), 3)
self.assertEqual(
frappe.db.get_value(
"Common Code",
{"code_list": import_result["code_list"], "common_code": "A"},
"title",
),
"Alpha",
)
def test_import_genericode_from_local_file_url(self):
source_file = frappe.get_doc(
{
"doctype": "File",
"file_name": "library_genericode.xml",
"content": SAMPLE_GENERICODE,
"is_private": 1,
}
).insert()
self.set_upload_context(file_name=source_file.file_name, file_url=source_file.file_url)
import_result = code_list_import.import_genericode()
self.assert_import_response(import_result)
def set_upload_context(
self,
content: bytes | None = None,
file_name: str = "genericode.xml",
file_url: str | None = None,
docname: str | None = None,
):
attrs = ("form_dict", "uploaded_file", "uploaded_file_url", "uploaded_filename")
originals = {attr: getattr(frappe.local, attr, None) for attr in attrs}
frappe.local.form_dict = frappe._dict(doctype="Code List", docname=docname)
frappe.local.uploaded_file = content
frappe.local.uploaded_file_url = file_url
frappe.local.uploaded_filename = file_name
def restore():
for attr, value in originals.items():
setattr(frappe.local, attr, value)
self.addCleanup(restore)
def assert_import_response(self, import_result):
self.assertEqual(
set(import_result),
{
"code_list",
"code_list_title",
"file",
"columns",
"example_values",
"filterable_columns",
},
)
self.assertEqual(import_result["code_list"], "test-code-list-v1")
self.assertEqual(import_result["code_list_title"], "Test Code List")
self.assertEqual(import_result["columns"], ["code", "name", "category"])
self.assertEqual(import_result["example_values"]["code"], ["A", "B", "C"])
self.assertEqual(import_result["example_values"]["name"], ["Alpha", "Beta", "Gamma"])
self.assertEqual(import_result["example_values"]["category"], ["Group 1", "Group 2", "Group 1"])
self.assertCountEqual(import_result["filterable_columns"]["category"], ["Group 1", "Group 2"])
self.assertTrue(frappe.db.exists("Code List", import_result["code_list"]))
self.assertTrue(frappe.db.exists("File", import_result["file"]))

View File

@@ -9,6 +9,8 @@ from frappe.model.document import Document
from frappe.utils.data import get_link_to_form
from lxml import etree
from erpnext.edi.doctype.code_list.code_list_import import parse_genericode_content, read_file_bytes
class CommonCode(Document):
# begin: auto-generated types
@@ -86,15 +88,15 @@ def simple_hash(input_string, length=6):
def import_genericode(code_list: str, file_name: str, column_map: dict, filters: dict | None = None):
"""Import genericode file and create Common Code entries"""
file_path = frappe.utils.file_manager.get_file_path(file_name)
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser=parser)
root = tree.getroot()
file_doc = frappe.get_doc("File", file_name)
file_doc.check_permission("read")
root = parse_genericode_content(read_file_bytes(file_doc))
# Construct the XPath expression
xpath_expr = ".//SimpleCodeList/Row"
filter_conditions = [
f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" for column_ref, value in filters.items()
f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'"
for column_ref, value in (filters or {}).items()
]
if filter_conditions:
xpath_expr += "[" + " and ".join(filter_conditions) + "]"
@@ -102,7 +104,7 @@ def import_genericode(code_list: str, file_name: str, column_map: dict, filters:
elements = root.xpath(xpath_expr)
total_elements = len(elements)
for i, xml_element in enumerate(elements, start=1):
common_code: "CommonCode" = frappe.new_doc("Common Code")
common_code: CommonCode = frappe.new_doc("Common Code")
common_code.code_list = code_list
common_code.from_genericode(column_map, xml_element)
common_code.save()

View File

@@ -120,7 +120,7 @@ class BlanketOrder(Document):
def validate_item_qty(self):
for d in self.items:
if d.qty < 0:
if flt(d.qty) < 0:
frappe.throw(_("Row {0}: Quantity cannot be negative.").format(d.idx))

View File

@@ -264,6 +264,7 @@ frappe.ui.form.on("BOM", {
reqd: 1,
default: 1,
onchange: () => {
if (!cur_dialog) return;
const { quantity, items: rm } = frm.doc;
const variant_items_map = rm.reduce((acc, item) => {
acc[item.item_code] = item.qty;
@@ -759,6 +760,8 @@ frappe.ui.form.on("BOM Item", "sourced_by_supplier", function (frm, cdt, cdn) {
if (d.sourced_by_supplier) {
d.rate = 0;
refresh_field("rate", d.name, d.parentfield);
} else {
get_bom_material_detail(frm.doc, cdt, cdn, false);
}
});

View File

@@ -31,34 +31,6 @@ frappe.ui.form.on("Job Card", {
};
});
frm.set_query("operation", "time_logs", () => {
let operations = (frm.doc.sub_operations || []).map((d) => d.sub_operation);
return {
filters: {
name: ["in", operations],
},
};
});
frm.set_query("work_order", function () {
return {
filters: {
status: ["not in", ["Cancelled", "Closed", "Stopped"]],
},
};
});
frm.events.set_company_filters(frm, "target_warehouse");
frm.events.set_company_filters(frm, "source_warehouse");
frm.events.set_company_filters(frm, "wip_warehouse");
frm.set_query("source_warehouse", "items", () => {
return {
filters: {
company: frm.doc.company,
},
};
});
frm.set_indicator_formatter("sub_operation", function (doc) {
if (doc.status == "Pending") {
return "red";
@@ -75,6 +47,14 @@ frappe.ui.form.on("Job Card", {
},
};
});
frm.set_query("work_order", function () {
return {
filters: {
status: ["not in", ["Cancelled", "Closed", "Stopped"]],
},
};
});
},
refresh: function (frm) {

View File

@@ -15,6 +15,7 @@
"bom_section",
"update_bom_costs_automatically",
"column_break_lhyt",
"allow_editing_of_items_and_quantities_in_work_order",
"section_break_6",
"default_wip_warehouse",
"default_fg_warehouse",
@@ -243,13 +244,20 @@
"fieldname": "enforce_time_logs",
"fieldtype": "Check",
"label": "Enforce Time Logs"
},
{
"default": "0",
"description": "If enabled, the system will allow users to edit the raw materials and their quantities in the Work Order. The system will not reset the quantities as per the BOM, if the user has changed them.",
"fieldname": "allow_editing_of_items_and_quantities_in_work_order",
"fieldtype": "Check",
"label": "Allow Editing of Items and Quantities in Work Order"
}
],
"icon": "icon-wrench",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-05-16 11:23:16.916512",
"modified": "2025-11-07 14:52:56.241459",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",

View File

@@ -18,6 +18,7 @@ class ManufacturingSettings(Document):
from frappe.types import DF
add_corrective_operation_cost_in_finished_good_valuation: DF.Check
allow_editing_of_items_and_quantities_in_work_order: DF.Check
allow_overtime: DF.Check
allow_production_on_holidays: DF.Check
backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"]

View File

@@ -16,7 +16,8 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Operation",
"options": "Operation"
"options": "Operation",
"reqd": 1
},
{
"default": "0",
@@ -40,7 +41,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-08-04 16:15:11.425349",
"modified": "2026-04-13 12:17:33.776504",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Sub Operation",

View File

@@ -16,7 +16,7 @@ class SubOperation(Document):
from frappe.types import DF
description: DF.SmallText | None
operation: DF.Link | None
operation: DF.Link
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -4,7 +4,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings, timeout
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc
@@ -509,7 +509,41 @@ class TestWorkOrder(FrappeTestCase):
def test_work_order_material_transferred_qty_with_process_loss(self):
stock_entries = []
bom = frappe.get_doc("BOM", {"docstatus": 1, "with_operations": 1, "company": "_Test Company"})
item_code = make_item("_Test Item For Process Loss", {"is_stock_item": 1}).name
rm_item_code = make_item("Test Item For Process Loss RM", {"is_stock_item": 1}).name
bom = make_bom(
item=item_code,
raw_materials=[rm_item_code],
with_operations=1,
do_not_save=True,
)
operation_name = "_Test Custom Operation"
workstation_name = "_Test Custom Workstation"
if not frappe.db.exists("Workstation", workstation_name):
doc = frappe.new_doc("Workstation")
doc.workstation_name = workstation_name
doc.save()
if not frappe.db.exists("Operation", operation_name):
doc = frappe.new_doc("Operation")
doc.name = operation_name
doc.workstation = workstation_name
doc.save()
operation = {
"operation": operation_name,
"workstation": workstation_name,
"description": "Test Data",
"operating_cost": 100,
"time_in_mins": 40,
}
bom.append("operations", operation)
bom.save()
bom.submit()
work_order = make_wo_order_test_record(
item=bom.item,
@@ -2395,7 +2429,7 @@ class TestWorkOrder(FrappeTestCase):
stock_entry.submit()
def test_disassembly_order_with_qty_behavior(self):
def test_disassembly_order_with_qty_from_wo_behavior(self):
# Create raw material and FG item
raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name
@@ -2435,27 +2469,9 @@ class TestWorkOrder(FrappeTestCase):
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_for_manufacture.submit()
# Simulate a disassembly stock entry
# Disassembly via WO required_items path (no source_stock_entry)
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
stock_entry.append(
"items",
{
"item_code": fg_item,
"qty": disassemble_qty,
"s_warehouse": wo.fg_warehouse,
},
)
for bom_item in bom.items:
stock_entry.append(
"items",
{
"item_code": bom_item.item_code,
"qty": (bom_item.qty / bom.quantity) * disassemble_qty,
"t_warehouse": wo.source_warehouse,
},
)
wo.reload()
stock_entry.save()
@@ -2470,7 +2486,7 @@ class TestWorkOrder(FrappeTestCase):
f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}",
)
# Assert raw materials
# Assert raw materials - qty scaled from WO required_items
for item in stock_entry.items:
if item.item_code == fg_item:
continue
@@ -2494,10 +2510,35 @@ class TestWorkOrder(FrappeTestCase):
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
)
# Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path
# (first disassembly auto-set source_stock_entry since there's only one manufacture entry)
disassemble_qty_2 = 2
stock_entry_2 = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name
)
)
stock_entry_2.save()
stock_entry_2.submit()
# All rows must trace back to se_for_manufacture
for item in stock_entry_2.items:
self.assertEqual(item.against_stock_entry, se_for_manufacture.name)
self.assertTrue(item.ste_detail)
# RM qty scaled from the manufacture SE rows
rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None)
expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2
self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3)
wo.reload()
self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2)
def test_disassembly_with_multiple_manufacture_entries(self):
"""
Test that disassembly does not create duplicate items when manufacturing
is done in multiple batches (multiple manufacture stock entries).
is done in multiple batches (multiple manufacture stock entries), including
secondary/scrap items.
Scenario:
1. Create Work Order for 10 units
@@ -2506,11 +2547,17 @@ class TestWorkOrder(FrappeTestCase):
4. Create Disassembly for 4 units
5. Verify no duplicate items in the disassembly stock entry
"""
# Create RM and FG item
# Create RM, scrap and FG item
raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
bom = make_bom(
item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2, do_not_submit=True
)
# add scrap item
bom.append("scrap_items", {"item_code": scrap_item, "stock_qty": 10})
bom.submit()
# Create WO
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
@@ -2585,7 +2632,7 @@ class TestWorkOrder(FrappeTestCase):
f"Found duplicate items in disassembly stock entry: {duplicates}",
)
expected_items = 3 # FG item + 2 raw materials
expected_items = 4 # FG item + 2 raw materials + 1 scrap item
self.assertEqual(
len(stock_entry.items),
expected_items,
@@ -2596,6 +2643,16 @@ class TestWorkOrder(FrappeTestCase):
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty)
# Scrap item: should be taken from scrap warehouse in disassembly
scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None)
self.assertIsNotNone(scrap_row)
self.assertEqual(scrap_row.is_scrap_item, 1)
self.assertTrue(scrap_row.s_warehouse)
self.assertFalse(scrap_row.t_warehouse)
self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse)
# BOM has scrap_qty=10/FG, total produced = 10*10 = 100, disassemble 4/10 → 40
self.assertEqual(scrap_row.qty, 40)
# RM quantities
for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
@@ -2607,19 +2664,57 @@ class TestWorkOrder(FrappeTestCase):
msg=f"Raw material {bom_item.item_code} qty mismatch",
)
# -- BOM-path disassembly (no source_stock_entry, no work_order) --
make_stock_entry_test_record(
item_code=scrap_item,
purpose="Material Receipt",
target=wo.fg_warehouse,
qty=50,
basic_rate=10,
)
bom_disassemble_qty = 2
bom_se = frappe.get_doc(
{
"doctype": "Stock Entry",
"stock_entry_type": "Disassemble",
"purpose": "Disassemble",
"from_bom": 1,
"bom_no": bom.name,
"fg_completed_qty": bom_disassemble_qty,
"from_warehouse": wo.fg_warehouse,
"to_warehouse": wo.wip_warehouse,
"company": wo.company,
"posting_date": nowdate(),
"posting_time": nowtime(),
}
)
bom_se.get_items()
bom_se.save()
bom_se.submit()
bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None)
self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly")
# v15: BOM scrap_qty=10/FG, no process_loss_per field → qty = 10 * 2 = 20
self.assertEqual(
bom_scrap_row.qty,
20,
f"BOM-path disassembly scrap qty mismatch; expected 20, got {bom_scrap_row.qty}",
)
def test_disassembly_with_additional_rm_not_in_bom(self):
"""
Test that disassembly correctly handles additional raw materials that were
manually added during manufacturing (not part of the BOM).
Test that SE-linked disassembly includes additional raw materials
that were manually added during manufacturing (not part of the BOM).
Scenario:
1. Create Work Order for 10 units with 2 raw materials in BOM
2. Transfer raw materials for manufacture
3. Manufacture in 2 parts (3 units, then 7 units)
4. In each manufacture entry, manually add an extra consumable item
(not in BOM) in proportion to the manufactured qty
5. Create Disassembly for 4 units
6. Verify that the additional RM is included in disassembly with proportional qty
5. Disassemble 3 units linked to first manufacture entry
6. Verify additional RM is included with correct proportional qty from SE1
"""
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
@@ -2655,9 +2750,8 @@ class TestWorkOrder(FrappeTestCase):
se_for_material_transfer.save()
se_for_material_transfer.submit()
# First Manufacture Entry - 3 units
# First Manufacture Entry - 3 units with additional RM
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
# Additional RM
se_manufacture1.append(
"items",
{
@@ -2670,9 +2764,8 @@ class TestWorkOrder(FrappeTestCase):
se_manufacture1.save()
se_manufacture1.submit()
# Second Manufacture Entry - 7 units
# Second Manufacture Entry - 7 units with additional RM
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
# AAdditional RM
se_manufacture2.append(
"items",
{
@@ -2688,13 +2781,15 @@ class TestWorkOrder(FrappeTestCase):
wo.reload()
self.assertEqual(wo.produced_qty, 10)
# Disassembly for 4 units
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
# Disassemble 3 units linked to first manufacture entry
disassemble_qty = 3
stock_entry = frappe.get_doc(
make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name)
)
stock_entry.save()
stock_entry.submit()
# No duplicate
# No duplicates
item_counts = {}
for item in stock_entry.items:
item_code = item.item_code
@@ -2707,16 +2802,15 @@ class TestWorkOrder(FrappeTestCase):
f"Found duplicate items in disassembly stock entry: {duplicates}",
)
# Additional RM qty
# Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM)
additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
self.assertIsNotNone(
additional_rm_row,
f"Additional raw material {additional_rm} not found in disassembly",
)
# intentional full reversal as not part of BOM
# eg: dies or consumables used during manufacturing
expected_additional_rm_qty = 3 + 7
# SE1 had 3 additional RM for 3 manufactured units, disassembling all 3
expected_additional_rm_qty = 3
self.assertAlmostEqual(
additional_rm_row.qty,
expected_additional_rm_qty,
@@ -2724,7 +2818,7 @@ class TestWorkOrder(FrappeTestCase):
msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
)
# RM qty
# BOM RM qty — scaled from SE1's rows
for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
@@ -2740,6 +2834,7 @@ class TestWorkOrder(FrappeTestCase):
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty)
# FG + 2 BOM RM + 1 additional RM = 4 items
expected_items = 4
self.assertEqual(
len(stock_entry.items),
@@ -2747,6 +2842,282 @@ class TestWorkOrder(FrappeTestCase):
f"Expected {expected_items} items, found {len(stock_entry.items)}",
)
# Verify traceability
for item in stock_entry.items:
self.assertEqual(item.against_stock_entry, se_manufacture1.name)
self.assertTrue(item.ste_detail)
def test_disassembly_auto_sets_source_stock_entry(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started")
make_stock_entry_test_record(
item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100
)
se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
for item in se_transfer.items:
item.s_warehouse = wo.wip_warehouse
se_transfer.save()
se_transfer.submit()
se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_manufacture.submit()
# Disassemble without specifying source_stock_entry
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3))
stock_entry.save()
# source_stock_entry should be auto-set since only one manufacture entry
self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name)
# All items should have against_stock_entry linked
for item in stock_entry.items:
self.assertEqual(item.against_stock_entry, se_manufacture.name)
self.assertTrue(item.ste_detail)
stock_entry.submit()
def test_disassembly_batch_tracked_items(self):
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
wip_wh = "_Test Warehouse - _TC"
rm_item = make_item(
"Test Batch RM for Disassembly SB",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBRD-RM-.###",
},
).name
fg_item = make_item(
"Test Batch FG for Disassembly SB",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBRD-FG-.###",
},
).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
wo = make_wo_order_test_record(
production_item=fg_item,
qty=6,
bom_no=bom.name,
skip_transfer=1,
source_warehouse=wip_wh,
status="Not Started",
)
# Two separate RM receipts → two distinct batches (batch_1, batch_2)
rm_receipt_1 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_batch_1 = get_batch_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_1.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
rm_receipt_2 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_batch_2 = get_batch_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_2.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches")
fg_batch_1 = make_batch(frappe._dict(item=fg_item))
fg_batch_2 = make_batch(frappe._dict(item=fg_item))
# Manufacture entry 1 — 3 FG using batch_1 RM/FG
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_1.items:
if row.item_code == rm_item:
row.batch_no = rm_batch_1
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.batch_no = fg_batch_1
row.use_serial_batch_fields = 1
se_manufacture_1.save()
se_manufacture_1.submit()
# Manufacture entry 2 — 3 FG using batch_2 RM/FG
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_2.items:
if row.item_code == rm_item:
row.batch_no = rm_batch_2
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.batch_no = fg_batch_2
row.use_serial_batch_fields = 1
se_manufacture_2.save()
se_manufacture_2.submit()
# Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's
disassemble_qty = 2
stock_entry = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
)
)
stock_entry.save()
stock_entry.submit()
# FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear)
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertIsNotNone(fg_row)
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1)
self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2)
# RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear)
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
self.assertIsNotNone(rm_row)
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1)
self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2)
# RM qty: 2 FG disassembled x 2 RM per FG = 4
self.assertAlmostEqual(rm_row.qty, 4.0, places=3)
def test_disassembly_serial_tracked_items(self):
from frappe.model.naming import make_autoname
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
wip_wh = "_Test Warehouse - _TC"
rm_item = make_item(
"Test Serial RM for Disassembly SB",
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"},
).name
fg_item = make_item(
"Test Serial FG for Disassembly SB",
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"},
).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
wo = make_wo_order_test_record(
production_item=fg_item,
qty=6,
bom_no=bom.name,
skip_transfer=1,
source_warehouse=wip_wh,
status="Not Started",
)
# Two separate RM receipts → two disjoint sets of serial numbers
rm_receipt_1 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_serials_1 = get_serial_nos_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_1.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertEqual(len(rm_serials_1), 6)
rm_receipt_2 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_serials_2 = get_serial_nos_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_2.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertEqual(len(rm_serials_2), 6)
self.assertFalse(
set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets"
)
# Pre-generate two sets of FG serial numbers
series = frappe.db.get_value("Item", fg_item, "serial_no_series")
fg_serials_1 = [make_autoname(series) for _ in range(3)]
fg_serials_2 = [make_autoname(series) for _ in range(3)]
# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_1.items:
if row.item_code == rm_item:
row.serial_no = "\n".join(rm_serials_1)
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.serial_no = "\n".join(fg_serials_1)
row.use_serial_batch_fields = 1
se_manufacture_1.save()
se_manufacture_1.submit()
# Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_2.items:
if row.item_code == rm_item:
row.serial_no = "\n".join(rm_serials_2)
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.serial_no = "\n".join(fg_serials_2)
row.use_serial_batch_fields = 1
se_manufacture_2.save()
se_manufacture_2.submit()
# Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's
disassemble_qty = 2
stock_entry = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
)
)
stock_entry.save()
stock_entry.submit()
# FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertIsNotNone(fg_row)
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle)
self.assertEqual(len(fg_dasm_serials), disassemble_qty)
self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1)))
self.assertFalse(
set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials"
)
# RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
self.assertIsNotNone(rm_row)
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle)
self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2)
self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1)))
self.assertFalse(
set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials"
)
def test_components_alternate_item_for_bom_based_manufacture_entry(self):
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)

View File

@@ -243,6 +243,20 @@ frappe.ui.form.on("Work Order", {
frm.trigger("add_custom_button_to_return_components");
frm.trigger("allow_alternative_item");
frm.trigger("toggle_items_editable");
},
toggle_items_editable(frm) {
let allow_edit = true;
if (!frm.doc.__onload?.allow_editing_items) allow_edit = false;
frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit);
frm.set_df_property("required_items", "cannot_add_rows", !allow_edit);
const grid = frm.fields_dict["required_items"].grid;
grid.update_docfield_property("item_code", "read_only", !allow_edit);
grid.update_docfield_property("required_qty", "read_only", !allow_edit);
grid.refresh();
},
add_custom_button_to_return_components: function (frm) {
@@ -401,7 +415,7 @@ frappe.ui.form.on("Work Order", {
make_disassembly_order(frm) {
erpnext.work_order
.show_prompt_for_qty_input(frm, "Disassemble")
.show_disassembly_prompt(frm)
.then((data) => {
if (flt(data.qty) <= 0) {
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>."));
@@ -411,11 +425,14 @@ frappe.ui.form.on("Work Order", {
work_order_id: frm.doc.name,
purpose: "Disassemble",
qty: data.qty,
source_stock_entry: data.source_stock_entry,
});
})
.then((stock_entry) => {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
if (stock_entry) {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
}
});
},
@@ -865,6 +882,60 @@ erpnext.work_order = {
return flt(max, precision("qty"));
},
show_disassembly_prompt: function (frm) {
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
let fields = [
{
fieldtype: "Link",
label: __("Source Manufacture Entry"),
fieldname: "source_stock_entry",
options: "Stock Entry",
description: __("Optional. Select a specific manufacture entry to reverse."),
get_query: () => {
return {
filters: {
work_order: frm.doc.name,
purpose: "Manufacture",
docstatus: 1,
},
};
},
onchange: async function () {
if (!frm.disassembly_prompt) return;
let se_name = this.value;
let qty = max_qty;
if (se_name) {
qty = await frappe.xcall(
"erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
{ stock_entry_name: se_name }
);
}
frm.disassembly_prompt.set_value("qty", qty);
frm.disassembly_prompt.fields_dict.qty.set_description(__("Max: {0}", [qty]));
},
},
{
fieldtype: "Float",
label: __("Qty for {0}", [__("Disassemble")]),
fieldname: "qty",
description: __("Max: {0}", [max_qty]),
default: max_qty,
},
];
return new Promise((resolve, reject) => {
frm.disassembly_prompt = frappe.prompt(
fields,
(data) => resolve(data),
__("Disassemble"),
__("Create")
);
});
},
show_prompt_for_qty_input: function (frm, purpose) {
let max = this.get_max_transferable_qty(frm, purpose);

View File

@@ -141,6 +141,7 @@ class WorkOrder(Document):
def onload(self):
ms = frappe.get_doc("Manufacturing Settings")
self.set_onload("allow_editing_items", ms.allow_editing_of_items_and_quantities_in_work_order)
self.set_onload("material_consumption", ms.material_consumption)
self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on)
self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order)
@@ -167,7 +168,11 @@ class WorkOrder(Document):
validate_uom_is_integer(self, "stock_uom", ["required_qty"])
self.set_required_items(reset_only_qty=len(self.get("required_items")))
if not len(self.get("required_items")) or not frappe.db.get_single_value(
"Manufacturing Settings", "allow_editing_of_items_and_quantities_in_work_order"
):
self.set_required_items(reset_only_qty=len(self.get("required_items")))
self.validate_operations_sequence()
def validate_operations_sequence(self):
@@ -223,6 +228,18 @@ class WorkOrder(Document):
if self.production_plan_sub_assembly_item:
return
production_item = self.production_item
if self.material_request_item and (
mr_plan_item := frappe.get_value(
"Material Request Item", self.material_request_item, "material_request_plan_item"
)
):
if main_item_code := frappe.get_value(
"Material Request Plan Item", mr_plan_item, "main_item_code"
):
production_item = main_item_code
if self.sales_order:
self.check_sales_order_on_hold_or_close()
@@ -243,8 +260,8 @@ class WorkOrder(Document):
& (SalesOrder.docstatus == 1)
& (SalesOrder.name == self.sales_order)
& (
(SalesOrderItem.item_code == self.production_item)
| (ProductBundleItem.item_code == self.production_item)
(SalesOrderItem.item_code == production_item)
| (ProductBundleItem.item_code == production_item)
)
)
.run(as_dict=1)
@@ -263,7 +280,7 @@ class WorkOrder(Document):
& (SalesOrder.skip_delivery_note == 0)
& (SalesOrderItem.item_code == PackedItem.parent_item)
& (SalesOrder.docstatus == 1)
& (PackedItem.item_code == self.production_item)
& (PackedItem.item_code == production_item)
)
.run(as_dict=1)
)
@@ -1480,7 +1497,13 @@ def set_work_order_ops(name):
@frappe.whitelist()
def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
def make_stock_entry(
work_order_id: str,
purpose: str,
qty: float | None = None,
target_warehouse: str | None = None,
source_stock_entry: str | None = None,
):
work_order = frappe.get_doc("Work Order", work_order_id)
if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
wip_warehouse = work_order.wip_warehouse
@@ -1517,6 +1540,8 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
if purpose == "Disassemble":
stock_entry.from_warehouse = work_order.fg_warehouse
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
if source_stock_entry:
stock_entry.source_stock_entry = source_stock_entry
stock_entry.set_stock_entry_type()
stock_entry.get_items()
@@ -1527,6 +1552,28 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
return stock_entry.as_dict()
@frappe.whitelist()
def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float:
se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True)
if not se:
return 0.0
filters = {
"source_stock_entry": stock_entry_name,
"purpose": "Disassemble",
"docstatus": 1,
}
if current_se_name:
filters["name"] = ("!=", current_se_name)
already_disassembled = flt(
frappe.db.get_value("Stock Entry", filters, "sum(fg_completed_qty)", order_by=None)
)
return flt(se.fg_completed_qty) - already_disassembled
@frappe.whitelist()
def get_default_warehouse():
doc = frappe.get_cached_doc("Manufacturing Settings")

View File

@@ -151,7 +151,7 @@
],
"istable": 1,
"links": [],
"modified": "2024-11-19 15:48:16.823384",
"modified": "2025-12-02 11:16:05.081613",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -10,6 +10,6 @@ frappe.listview_settings["Workstation"] = {
Setup: "blue",
};
return [__(doc.status), color_map[doc.status], true];
return [__(doc.status), color_map[doc.status], "status,=," + doc.status];
},
};

View File

@@ -432,3 +432,4 @@ erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
erpnext.patches.v15_0.replace_http_with_https_in_sales_partner
erpnext.patches.v16_0.add_portal_redirects
erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
erpnext.patches.v16_0.depends_on_inv_dimensions

View File

@@ -0,0 +1,89 @@
import frappe
def get_inventory_dimensions():
return frappe.get_all(
"Inventory Dimension",
fields=[
"target_fieldname as fieldname",
"source_fieldname",
"reference_document as doctype",
"reqd",
"mandatory_depends_on",
],
order_by="creation",
distinct=True,
)
def get_display_depends_on(doctype, fieldname):
if doctype not in [
"Stock Entry Detail",
"Sales Invoice Item",
"Delivery Note Item",
"Purchase Invoice Item",
"Purchase Receipt Item",
]:
return None, None
fieldname_start_with = "to"
display_depends_on = ""
if doctype in ["Purchase Invoice Item", "Purchase Receipt Item"]:
display_depends_on = "eval:parent.is_internal_supplier == 1"
fieldname_start_with = "from"
elif doctype != "Stock Entry Detail":
display_depends_on = "eval:parent.is_internal_customer == 1"
elif doctype == "Stock Entry Detail":
display_depends_on = "eval:doc.t_warehouse"
return f"{fieldname_start_with}_{fieldname}", display_depends_on
def execute():
for dimension in get_inventory_dimensions():
if frappe.db.exists(
"Custom Field", {"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail"}
):
frappe.set_value(
"Custom Field",
{"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail"},
"depends_on",
"eval:doc.s_warehouse",
)
if frappe.db.exists(
"Custom Field", {"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail", "reqd": 1}
):
frappe.set_value(
"Custom Field",
{"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail", "reqd": 1},
{"mandatory_depends_on": "eval:doc.s_warehouse", "reqd": 0},
)
if frappe.db.exists(
"Custom Field",
{
"fieldname": f"to_{dimension.fieldname}",
"dt": "Stock Entry Detail",
"depends_on": "eval:parent.purpose != 'Material Issue'",
},
):
frappe.set_value(
"Custom Field",
{
"fieldname": f"to_{dimension.fieldname}",
"dt": "Stock Entry Detail",
"depends_on": "eval:parent.purpose != 'Material Issue'",
},
"depends_on",
"eval:doc.t_warehouse",
)
fieldname, display_depends_on = get_display_depends_on(dimension.doctype, dimension.fieldname)
if display_depends_on and frappe.db.exists(
"Custom Field", {"fieldname": fieldname, "dt": dimension.doctype}
):
frappe.set_value(
"Custom Field",
{"fieldname": fieldname, "dt": dimension.doctype},
"mandatory_depends_on",
display_depends_on if dimension.reqd else dimension.mandatory_depends_on,
)

View File

@@ -10,7 +10,18 @@ def execute():
)
if data:
frappe.db.auto_commit_on_many_writes = 1
frappe.db.bulk_update(
"Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data}
)
frappe.db.auto_commit_on_many_writes = 0
try:
frappe.db.bulk_update(
"Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data}
)
quotations = frappe.get_all(
"Quotation Item",
filters={"name": ["in", [d.quotation_item for d in data]]},
pluck="parent",
distinct=True,
)
for quotation in quotations:
doc = frappe.get_doc("Quotation", quotation)
doc.set_status(update=True, update_modified=False)
finally:
frappe.db.auto_commit_on_many_writes = 0

View File

@@ -8,6 +8,7 @@ import frappe
from frappe.tests.utils import change_settings
from frappe.utils import add_to_date, now_datetime, nowdate
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
from erpnext.setup.doctype.employee.test_employee import make_employee
@@ -202,6 +203,58 @@ class TestTimesheet(unittest.TestCase):
ts.calculate_percentage_billed()
self.assertEqual(ts.per_billed, 100)
def test_partial_billing_and_return(self):
"""
Test Timesheet status transitions during partial billing, full billing,
sales return, and return cancellation.
Scenario:
1. Create a Timesheet with two billable time logs.
2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed.
3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed.
4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed.
5. Cancel the Sales Return → Timesheet returns to Billed status.
This test ensures Timesheet status is recalculated correctly
across billing and return lifecycle events.
"""
emp = make_employee("test_employee_6@salary.com")
timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
timesheet_detail = timesheet.append("time_logs", {})
timesheet_detail.is_billable = 1
timesheet_detail.activity_type = "_Test Activity Type"
timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1)
timesheet_detail.hours = 2
timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta(
hours=timesheet_detail.hours
)
timesheet.save().submit()
sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
sales_invoice.due_date = nowdate()
sales_invoice.timesheets.pop()
sales_invoice.submit()
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
self.assertEqual(timesheet_status, "Partially Billed")
sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
sales_invoice2.due_date = nowdate()
sales_invoice2.submit()
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
self.assertEqual(timesheet_status, "Billed")
sales_return = make_sales_return(sales_invoice2.name).submit()
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
self.assertEqual(timesheet_status, "Partially Billed")
sales_return.load_from_db()
sales_return.cancel()
timesheet.load_from_db()
self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name)
self.assertEqual(timesheet.status, "Billed")
def make_timesheet(
employee,
@@ -211,6 +264,7 @@ def make_timesheet(
project=None,
task=None,
company=None,
do_not_submit=False,
):
update_activity_type(activity_type)
timesheet = frappe.new_doc("Timesheet")
@@ -237,7 +291,8 @@ def make_timesheet(
else:
timesheet.save(ignore_permissions=True)
timesheet.submit()
if not do_not_submit:
timesheet.submit()
return timesheet

View File

@@ -91,7 +91,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled",
"options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled",
"print_hide": 1,
"read_only": 1
},
@@ -310,7 +310,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-04-20 15:59:11.107831",
"modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet",

View File

@@ -50,7 +50,9 @@ class Timesheet(Document):
per_billed: DF.Percent
sales_invoice: DF.Link | None
start_date: DF.Date | None
status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"]
status: DF.Literal[
"Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled"
]
time_logs: DF.Table[TimesheetDetail]
title: DF.Data | None
total_billable_amount: DF.Currency
@@ -126,6 +128,9 @@ class Timesheet(Document):
if flt(self.per_billed, self.precision("per_billed")) >= 100.0:
self.status = "Billed"
if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0:
self.status = "Partially Billed"
if self.sales_invoice:
self.status = "Completed"
@@ -423,7 +428,9 @@ def get_timesheet_data(name, project):
@frappe.whitelist()
def make_sales_invoice(source_name, item_code=None, customer=None, currency=None):
def make_sales_invoice(
source_name: str, item_code: str | None = None, customer: str | None = None, currency: str | None = None
):
target = frappe.new_doc("Sales Invoice")
timesheet = frappe.get_doc("Timesheet", source_name)
@@ -452,7 +459,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None
target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate})
for time_log in timesheet.time_logs:
if time_log.is_billable:
if time_log.is_billable and not time_log.sales_invoice:
target.append(
"timesheets",
{

View File

@@ -1,6 +1,9 @@
frappe.listview_settings["Timesheet"] = {
add_fields: ["status", "total_hours", "start_date", "end_date"],
get_indicator: function (doc) {
if (doc.status == "Partially Billed") {
return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"];
}
if (doc.status == "Billed") {
return [__("Billed"), "green", "status,=," + "Billed"];
}

View File

@@ -674,24 +674,27 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
set_rounded_total() {
var disable_rounded_total = 0;
if(frappe.meta.get_docfield(this.frm.doc.doctype, "disable_rounded_total", this.frm.doc.name)) {
if (frappe.meta.get_docfield(this.frm.doc.doctype, "disable_rounded_total", this.frm.doc.name)) {
disable_rounded_total = this.frm.doc.disable_rounded_total;
} else if (frappe.sys_defaults.disable_rounded_total) {
disable_rounded_total = frappe.sys_defaults.disable_rounded_total;
}
if (cint(disable_rounded_total)) {
this.frm.doc.rounded_total = 0;
this.frm.doc.base_rounded_total = 0;
this.frm.doc.rounding_adjustment = 0;
return;
}
if(frappe.meta.get_docfield(this.frm.doc.doctype, "rounded_total", this.frm.doc.name)) {
this.frm.doc.rounded_total = round_based_on_smallest_currency_fraction(this.frm.doc.grand_total,
this.frm.doc.currency, precision("rounded_total"));
this.frm.doc.rounding_adjustment = flt(this.frm.doc.rounded_total - this.frm.doc.grand_total,
precision("rounding_adjustment"));
if (frappe.meta.get_docfield(this.frm.doc.doctype, "rounded_total", this.frm.doc.name)) {
if (cint(disable_rounded_total)) {
this.frm.doc.rounded_total = 0;
this.frm.doc.rounding_adjustment = 0;
} else {
this.frm.doc.rounded_total = round_based_on_smallest_currency_fraction(
this.frm.doc.grand_total,
this.frm.doc.currency,
precision("rounded_total"),
);
this.frm.doc.rounding_adjustment = flt(
this.frm.doc.rounded_total - this.frm.doc.grand_total,
precision("rounding_adjustment"),
);
}
this.set_in_company_currency(this.frm.doc, ["rounding_adjustment", "rounded_total"]);
}

View File

@@ -38,7 +38,7 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm
{
fieldtype: "Section Break",
label: __("Primary Contact Details"),
collapsible: 1,
collapsible: 0,
},
{
label: __("First Name"),
@@ -69,7 +69,7 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm
{
fieldtype: "Section Break",
label: __("Primary Address Details"),
collapsible: 1,
collapsible: 0,
},
{
label: __("Address Line 1"),

View File

@@ -0,0 +1,4 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}

View File

@@ -0,0 +1,4 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}

View File

@@ -0,0 +1,4 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}

View File

@@ -1,9 +1,12 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe import _
from frappe.model.document import Document
from erpnext import get_region
class SouthAfricaVATSettings(Document):
# begin: auto-generated types
@@ -22,4 +25,9 @@ class SouthAfricaVATSettings(Document):
vat_accounts: DF.Table[SouthAfricaVATAccount]
# end: auto-generated types
pass
def validate(self):
self.validate_company_region()
def validate_company_region(self):
if self.company and get_region(self.company) != "South Africa":
frappe.throw(_("Company {0} is not in South Africa.").format(frappe.bold(self.company)))

View File

@@ -148,6 +148,7 @@ def make_customer():
"doctype": "Customer",
"customer_name": "_Test UAE Customer",
"customer_type": "Company",
"customer_group": "Individual",
}
)
customer.insert()

View File

@@ -115,6 +115,7 @@ def make_customer():
"doctype": "Customer",
"customer_name": "_Test SA Customer",
"customer_type": "Company",
"customer_group": "Individual",
}
).insert()

View File

@@ -10,6 +10,13 @@ frappe.query_reports["VAT Audit Report"] = {
options: "Company",
reqd: 1,
default: frappe.defaults.get_user_default("Company"),
get_query: function () {
return {
filters: {
country: "South Africa",
},
};
},
},
{
fieldname: "from_date",

View File

@@ -6,8 +6,11 @@ import json
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce, NullIf
from frappe.utils import formatdate, get_link_to_form
from erpnext import get_region
def execute(filters=None):
return VATAuditReport(filters).run()
@@ -21,19 +24,10 @@ class VATAuditReport:
self.doctypes = ["Purchase Invoice", "Sales Invoice"]
def run(self):
self.validate_company_region()
self.get_sa_vat_accounts()
self.get_columns()
for doctype in self.doctypes:
self.select_columns = """
name as voucher_no,
posting_date, remarks"""
columns = (
", supplier as party, credit_to as account"
if doctype == "Purchase Invoice"
else ", customer as party, debit_to as account"
)
self.select_columns += columns
self.get_invoice_data(doctype)
if self.invoices:
@@ -43,6 +37,14 @@ class VATAuditReport:
return self.columns, self.data
def validate_company_region(self):
if self.filters.company and get_region(self.filters.company) != "South Africa":
frappe.throw(
_(
"The company {0} is not in South Africa. VAT Audit Report is only available for companies in South Africa."
).format(frappe.bold(self.filters.company))
)
def get_sa_vat_accounts(self):
self.sa_vat_accounts = frappe.get_all(
"South Africa VAT Account", filters={"parent": self.filters.company}, pluck="account"
@@ -54,47 +56,59 @@ class VATAuditReport:
frappe.throw(_("Please set VAT Accounts in {0}").format(link_to_settings))
def get_invoice_data(self, doctype):
conditions = self.get_conditions()
self.invoices = frappe._dict()
invoice_data = frappe.db.sql(
f"""
SELECT
{self.select_columns}
FROM
`tab{doctype}`
WHERE
docstatus = 1 {conditions}
and is_opening = 'No'
ORDER BY
posting_date DESC
""",
self.filters,
as_dict=1,
invoice_doctype = frappe.qb.DocType(doctype)
party_field = invoice_doctype.supplier if doctype == "Purchase Invoice" else invoice_doctype.customer
account_field = (
invoice_doctype.credit_to if doctype == "Purchase Invoice" else invoice_doctype.debit_to
)
for d in invoice_data:
self.invoices.setdefault(d.voucher_no, d)
query = (
frappe.qb.from_(invoice_doctype)
.select(
invoice_doctype.name.as_("voucher_no"),
invoice_doctype.posting_date,
invoice_doctype.remarks,
party_field.as_("party"),
account_field.as_("account"),
)
.where(invoice_doctype.docstatus == 1)
.where(invoice_doctype.is_opening == "No")
.orderby(invoice_doctype.posting_date, order=frappe.qb.desc)
)
if self.filters.get("company"):
query = query.where(invoice_doctype.company == self.filters.company)
if self.filters.get("from_date"):
query = query.where(invoice_doctype.posting_date >= self.filters.from_date)
if self.filters.get("to_date"):
query = query.where(invoice_doctype.posting_date <= self.filters.to_date)
invoice_data = query.run(as_dict=True)
for row in invoice_data:
self.invoices.setdefault(row.voucher_no, row)
def get_invoice_items(self, doctype):
self.invoice_items = frappe._dict()
item_doctype = frappe.qb.DocType(doctype + " Item")
items = frappe.db.sql(
"""
SELECT
item_code, parent, base_net_amount, is_zero_rated
FROM
`tab{} Item`
WHERE
parent in ({})
""".format(doctype, ", ".join(["%s"] * len(self.invoices))),
tuple(self.invoices),
as_dict=1,
items = (
frappe.qb.from_(item_doctype)
.select(
Coalesce(NullIf(item_doctype.item_code, ""), item_doctype.item_name).as_("item"),
item_doctype.parent,
item_doctype.base_net_amount,
item_doctype.is_zero_rated,
)
.where(item_doctype.parent.isin(list(self.invoices.keys())))
.run(as_dict=True)
)
for d in items:
self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, {"net_amount": 0.0})
self.invoice_items[d.parent][d.item_code]["net_amount"] += d.get("base_net_amount", 0)
self.invoice_items[d.parent][d.item_code]["is_zero_rated"] = d.is_zero_rated
for row in items:
self.invoice_items.setdefault(row.parent, {}).setdefault(row.item, {"net_amount": 0.0})
self.invoice_items[row.parent][row.item]["net_amount"] += row.get("base_net_amount", 0)
self.invoice_items[row.parent][row.item]["is_zero_rated"] = row.is_zero_rated
def get_items_based_on_tax_rate(self, doctype):
self.items_based_on_tax_rate = frappe._dict()
@@ -103,52 +117,54 @@ class VATAuditReport:
"Purchase Taxes and Charges" if doctype == "Purchase Invoice" else "Sales Taxes and Charges"
)
self.tax_details = frappe.db.sql(
"""
SELECT
parent, account_head, item_wise_tax_detail
FROM
`tab{}`
WHERE
parenttype = {} and docstatus = 1
and parent in ({})
ORDER BY
account_head
""".format(self.tax_doctype, "%s", ", ".join(["%s"] * len(self.invoices.keys()))),
tuple([doctype, *list(self.invoices.keys())]),
tax_doctype = frappe.qb.DocType(self.tax_doctype)
self.tax_details = (
frappe.qb.from_(tax_doctype)
.select(tax_doctype.parent, tax_doctype.account_head, tax_doctype.item_wise_tax_detail)
.where(tax_doctype.parenttype == doctype)
.where(tax_doctype.docstatus == 1)
.where(tax_doctype.parent.isin(list(self.invoices.keys())))
.where(tax_doctype.account_head.isin(self.sa_vat_accounts))
.orderby(tax_doctype.account_head)
.run(as_dict=True)
)
for parent, account, item_wise_tax_detail in self.tax_details:
if item_wise_tax_detail:
try:
if account in self.sa_vat_accounts:
item_wise_tax_detail = json.loads(item_wise_tax_detail)
else:
continue
for item_code, taxes in item_wise_tax_detail.items():
is_zero_rated = self.invoice_items.get(parent).get(item_code).get("is_zero_rated")
# to skip items with non-zero tax rate in multiple rows
if taxes[0] == 0 and not is_zero_rated:
continue
tax_rate = self.get_item_amount_map(parent, item_code, taxes)
for tax_detail in self.tax_details:
if not tax_detail.item_wise_tax_detail:
continue
if tax_rate is not None:
rate_based_dict = self.items_based_on_tax_rate.setdefault(parent, {}).setdefault(
tax_rate, []
)
if item_code not in rate_based_dict:
rate_based_dict.append(item_code)
except ValueError:
try:
item_wise_tax_detail = json.loads(tax_detail.item_wise_tax_detail)
except ValueError:
continue
parent_items = self.invoice_items.get(tax_detail.parent, {})
parent_tax_rates = self.items_based_on_tax_rate.setdefault(tax_detail.parent, {})
for item, taxes in item_wise_tax_detail.items():
is_zero_rated = parent_items.get(item, {}).get("is_zero_rated")
# to skip items with non-zero tax rate in multiple rows
if taxes[0] == 0 and not is_zero_rated:
continue
def get_item_amount_map(self, parent, item_code, taxes):
net_amount = self.invoice_items.get(parent).get(item_code).get("net_amount")
tax_rate = self.get_item_amount_map(tax_detail.parent, item, taxes)
if tax_rate is not None:
rate_based_dict = parent_tax_rates.setdefault(tax_rate, [])
if item not in rate_based_dict:
rate_based_dict.append(item)
def get_item_amount_map(self, parent, item, taxes):
item_details = self.invoice_items.get(parent, {}).get(item)
if not item_details:
return None
net_amount = item_details.get("net_amount", 0)
tax_rate = taxes[0]
tax_amount = taxes[1]
gross_amount = net_amount + tax_amount
self.item_tax_rate.setdefault(parent, {}).setdefault(
item_code,
item,
{
"tax_rate": tax_rate,
"gross_amount": 0.0,
@@ -157,24 +173,12 @@ class VATAuditReport:
},
)
self.item_tax_rate[parent][item_code]["net_amount"] += net_amount
self.item_tax_rate[parent][item_code]["tax_amount"] += tax_amount
self.item_tax_rate[parent][item_code]["gross_amount"] += gross_amount
self.item_tax_rate[parent][item]["net_amount"] += net_amount
self.item_tax_rate[parent][item]["tax_amount"] += tax_amount
self.item_tax_rate[parent][item]["gross_amount"] += gross_amount
return tax_rate
def get_conditions(self):
conditions = ""
for opts in (
("company", " and company=%(company)s"),
("from_date", " and posting_date>=%(from_date)s"),
("to_date", " and posting_date<=%(to_date)s"),
):
if self.filters.get(opts[0]):
conditions += opts[1]
return conditions
def get_data(self, doctype):
consolidated_data = self.get_consolidated_data(doctype)
section_name = _("Purchases") if doctype == "Purchase Invoice" else _("Sales")

View File

@@ -145,6 +145,7 @@ class Customer(TransactionBase):
def validate(self):
self.flags.is_new_doc = self.is_new()
self.flags.old_lead = self.lead_name
self.validate_customer_group()
validate_party_accounts(self)
self.validate_credit_limit_on_change()
self.set_loyalty_program()
@@ -324,6 +325,17 @@ class Customer(TransactionBase):
frappe.NameError,
)
def validate_customer_group(self):
if not self.customer_group:
return
is_group = frappe.db.get_value("Customer Group", self.customer_group, "is_group")
if is_group:
frappe.throw(
_("Cannot select a Group type Customer Group. Please select a non-group Customer Group."),
title=_("Invalid Customer Group"),
)
def validate_credit_limit_on_change(self):
if self.get("__islocal") or not self.credit_limits:
return

View File

@@ -450,6 +450,7 @@ def make_customer(customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Individual"
customer.customer_group = "Individual"
customer.insert()
return customer.name
else:

View File

@@ -123,6 +123,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0)
) {
this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create"));
cur_frm.page.set_inner_btn_group_as_primary(__("Create"));
this.frm.add_custom_button(__("Update Items"), () => {
erpnext.utils.update_child_items({
frm: this.frm,
@@ -137,8 +138,6 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
this.frm.trigger("set_as_lost_dialog");
});
}
cur_frm.page.set_inner_btn_group_as_primary(__("Create"));
}
if (this.frm.doc.docstatus === 0 && frappe.model.can_read("Opportunity")) {

View File

@@ -188,7 +188,7 @@ class Quotation(SellingController):
)
for row in self._items:
if row.name not in ordered_items or row.qty > ordered_items[row.name]:
if row.name not in ordered_items or row.stock_qty > ordered_items[row.name]:
return "Partially Ordered"
return "Ordered"
@@ -409,9 +409,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
target.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
balance_qty = obj.qty if is_unit_price_row(obj) else obj.qty - ordered_items.get(obj.name, 0.0)
target.qty = balance_qty if balance_qty > 0 else 0
target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0)
target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0
target.qty = flt(target.stock_qty) / flt(obj.conversion_factor)
if obj.against_blanket_order:
target.against_blanket_order = obj.against_blanket_order
@@ -425,7 +425,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
3. If no selections: Simple row: Map if adequate qty
"""
if not ((item.qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
return False
if not selected_rows:

View File

@@ -63,6 +63,13 @@ frappe.ui.form.on("Sales Order", {
});
}
},
transaction_date(frm) {
prevent_past_delivery_dates(frm);
frm.set_value("delivery_date", "");
frm.doc.items.forEach((d) => {
frappe.model.set_value(d.doctype, d.name, "delivery_date", "");
});
},
refresh: function (frm) {
if (frm.doc.docstatus === 1) {

View File

@@ -6,6 +6,7 @@ import frappe
from frappe import _, msgprint
from frappe.core.doctype.sms_settings.sms_settings import send_sms
from frappe.model.document import Document
from frappe.query_builder import functions as fn
from frappe.utils import cstr
@@ -41,73 +42,117 @@ class SMSCenter(Document):
@frappe.whitelist()
def create_receiver_list(self):
rec, where_clause = "", ""
if self.send_to == "All Customer Contact":
where_clause = " and dl.link_doctype = 'Customer'"
if self.customer:
where_clause += (
" and dl.link_name = '%s'" % self.customer.replace("'", "'")
or " and ifnull(dl.link_name, '') != ''"
)
if self.send_to == "All Supplier Contact":
where_clause = " and dl.link_doctype = 'Supplier'"
if self.supplier:
where_clause += (
" and dl.link_name = '%s'" % self.supplier.replace("'", "'")
or " and ifnull(dl.link_name, '') != ''"
)
if self.send_to == "All Sales Partner Contact":
where_clause = " and dl.link_doctype = 'Sales Partner'"
if self.sales_partner:
where_clause += (
"and dl.link_name = '%s'" % self.sales_partner.replace("'", "'")
or " and ifnull(dl.link_name, '') != ''"
)
query = None
if self.send_to == "":
return
if self.send_to in [
"All Contact",
"All Customer Contact",
"All Supplier Contact",
"All Sales Partner Contact",
]:
rec = frappe.db.sql(
"""select CONCAT(ifnull(c.first_name,''), ' ', ifnull(c.last_name,'')),
c.mobile_no from `tabContact` c, `tabDynamic Link` dl where ifnull(c.mobile_no,'')!='' and
c.docstatus != 2 and dl.parent = c.name%s"""
% where_clause
)
query = self.get_contact_query_for_all_contacts()
elif self.send_to == "All Lead (Open)":
rec = frappe.db.sql(
"""select lead_name, mobile_no from `tabLead` where
ifnull(mobile_no,'')!='' and docstatus != 2 and status='Open'"""
)
query = self.get_contact_query_for_all_open_leads()
elif self.send_to == "All Employee (Active)":
where_clause = (
self.department and " and department = '%s'" % self.department.replace("'", "'") or ""
)
where_clause += self.branch and " and branch = '%s'" % self.branch.replace("'", "'") or ""
rec = frappe.db.sql(
"""select employee_name, cell_number from
`tabEmployee` where status = 'Active' and docstatus < 2 and
ifnull(cell_number,'')!='' %s"""
% where_clause
)
query = self.get_contact_query_for_all_active_employee()
elif self.send_to == "All Sales Person":
rec = frappe.db.sql(
"""select sales_person_name,
tabEmployee.cell_number from `tabSales Person` left join tabEmployee
on `tabSales Person`.employee = tabEmployee.name
where ifnull(tabEmployee.cell_number,'')!=''"""
)
query = self.get_contact_query_for_all_sales_person()
rec = query.run(as_list=1)
rec_list = ""
for d in rec:
rec_list += d[0] + " - " + d[1] + "\n"
self.receiver_list = rec_list
def get_contact_query_for_all_contacts(self):
Contact = frappe.qb.DocType("Contact")
DynamicLink = frappe.qb.DocType("Dynamic Link")
query = (
frappe.qb.from_(Contact)
.join(DynamicLink)
.on(DynamicLink.parent == Contact.name)
.select(
fn.Concat(fn.IfNull(Contact.first_name, ""), " ", fn.IfNull(Contact.last_name, "")),
Contact.mobile_no,
)
.where((fn.IfNull(Contact.mobile_no, "") != "") & (Contact.docstatus != 2))
)
if self.send_to == "All Customer Contact":
query = query.where(DynamicLink.link_doctype == "Customer")
query = (
query.where(DynamicLink.link_name == self.customer)
if self.customer
else query.where(fn.IfNull(DynamicLink.link_name, "") != "")
)
elif self.send_to == "All Supplier Contact":
query = query.where(DynamicLink.link_doctype == "Supplier")
query = (
query.where(DynamicLink.link_name == self.supplier)
if self.supplier
else query.where(fn.IfNull(DynamicLink.link_name, "") != "")
)
elif self.send_to == "All Sales Partner Contact":
query = query.where(DynamicLink.link_doctype == "Sales Partner")
query = (
query.where(DynamicLink.link_name == self.sales_partner)
if self.sales_partner
else query.where(fn.IfNull(DynamicLink.link_name, "") != "")
)
return query
def get_contact_query_for_all_open_leads(self):
Lead = frappe.qb.DocType("Lead")
query = (
frappe.qb.from_(Lead)
.select(Lead.lead_name, Lead.mobile)
.where((fn.IfNull(Lead.mobile_no, "") != "") & (Lead.docstatus != 2) & (Lead.status == "Open"))
)
return query
def get_contact_query_for_all_active_employee(self):
Employee = frappe.qb.DocType("Employee")
query = (
frappe.qb.from_(Employee)
.select(Employee.employee_name, Employee.cell_number)
.where(
(Employee.status == "Active")
& (Employee.docstatus != 2)
& (fn.IfNull(Employee.cell_number, "") != "")
)
)
if self.department:
query = query.where(Employee.department == self.department)
if self.branch:
query = query.where(Employee.branch == self.branch)
return query
def get_contact_query_for_all_sales_person(self):
SalesPerson = frappe.qb.DocType("Sales Person")
Employee = frappe.qb.DocType("Employee")
query = (
frappe.qb.from_(SalesPerson)
.left_join(Employee)
.on(SalesPerson.employee == Employee.name)
.select(SalesPerson.sales_person_name, Employee.cell_number)
.where(fn.IfNull(Employee.cell_number, "") != "")
)
return query
def get_receiver_nos(self):
receiver_nos = []
if self.receiver_list:

View File

@@ -189,7 +189,7 @@ class Employee(NestedSet):
frappe.throw(_("User {0} does not exist").format(self.user_id))
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
frappe.set_value("User", self.user_id, "enabled", not enabled)
frappe.db.set_value("User", self.user_id, "enabled", not enabled)
def validate_duplicate_user_id(self):
Employee = frappe.qb.DocType("Employee")

View File

@@ -759,7 +759,6 @@
"label": "Incoming Rate",
"no_copy": 1,
"options": "Company:company:default_currency",
"precision": "6",
"print_hide": 1,
"read_only": 1
},
@@ -952,7 +951,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-05-31 18:51:32.651562",
"modified": "2026-04-07 15:44:20.892151",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",

View File

@@ -8,9 +8,8 @@
"field_order": [
"dimension_details_tab",
"dimension_name",
"reference_document",
"column_break_4",
"disabled",
"reference_document",
"field_mapping_section",
"source_fieldname",
"column_break_9",
@@ -93,12 +92,6 @@
"fieldtype": "Check",
"label": "Apply to All Inventory Documents"
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{
"fieldname": "target_fieldname",
"fieldtype": "Data",
@@ -159,6 +152,7 @@
"label": "Conditional Rule Examples"
},
{
"depends_on": "eval:!doc.apply_to_all_doctypes",
"description": "To apply condition on parent field use parent.field_name and to apply condition on child table use doc.field_name. Here field_name could be based on the actual column name of the respective field.",
"fieldname": "mandatory_depends_on",
"fieldtype": "Small Text",
@@ -188,7 +182,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-07 15:51:29.329064",
"modified": "2026-04-08 10:10:16.884388",
"modified_by": "Administrator",
"module": "Stock",
"name": "Inventory Dimension",

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