Compare commits

..

106 Commits

Author SHA1 Message Date
Frappe PR Bot
2b3e3dfd83 chore(release): Bumped to Version 15.111.0
# [15.111.0](https://github.com/frappe/erpnext/compare/v15.110.0...v15.111.0) (2026-06-10)

### Bug Fixes

* **accounts:** include asset items in purchase receipt validation ([#55150](https://github.com/frappe/erpnext/issues/55150)) ([611849f](611849f953))
* add permission checks in accounts whitelisted methods ([2d1c0dc](2d1c0dcb53))
* bypass project permission check when updating consumed material … (backport [#55645](https://github.com/frappe/erpnext/issues/55645)) ([#55706](https://github.com/frappe/erpnext/issues/55706)) ([cedd0a1](cedd0a1903))
* **cheque_print_template:** print format creation from cheque print template requires system manager (backport [#55708](https://github.com/frappe/erpnext/issues/55708)) ([#55711](https://github.com/frappe/erpnext/issues/55711)) ([2f5b93e](2f5b93e308))
* correct field order in Address and Contacts report ([bde46cf](bde46cffd0))
* correct field order in Address and Contacts report ([08f3cf9](08f3cf98f9))
* do not allow to make changes in SABB after submit ([07b6111](07b61113af))
* drop ignore_permissions handling from add_ac ([2d0e3fd](2d0e3fd9af))
* duplicating a Customer/Supplier shouldn't inherit the source's primary contact and address (backport [#55421](https://github.com/frappe/erpnext/issues/55421)) ([#55608](https://github.com/frappe/erpnext/issues/55608)) ([013bd1a](013bd1a566))
* handle multi-select stock ageing filters ([#55776](https://github.com/frappe/erpnext/issues/55776)) ([95f46df](95f46dfc01))
* **item:** format integer numeric variant attributes without decimals (backport [#55561](https://github.com/frappe/erpnext/issues/55561)) ([#55563](https://github.com/frappe/erpnext/issues/55563)) ([4772799](4772799db2))
* naming series issue ([e7eaa87](e7eaa87a77))
* persist main item code for MR plan items ([#55623](https://github.com/frappe/erpnext/issues/55623)) ([e8e0514](e8e0514a30))
* prevent leakage of party-derived fields in cross doctype transactions (backport [#55336](https://github.com/frappe/erpnext/issues/55336)) ([#55578](https://github.com/frappe/erpnext/issues/55578)) ([0e64acb](0e64acb0fa))
* prevent selling items from sample retention warehouse (backport [#55613](https://github.com/frappe/erpnext/issues/55613)) ([#55633](https://github.com/frappe/erpnext/issues/55633)) ([c15012c](c15012cd51))
* **process statement of accounts:** validate pdf_name and validate permission before triggering send_auto_email (backport [#55781](https://github.com/frappe/erpnext/issues/55781)) ([#55782](https://github.com/frappe/erpnext/issues/55782)) ([18ca96c](18ca96c36b))
* remove item name from update items dialog item code column (backport [#55718](https://github.com/frappe/erpnext/issues/55718)) ([#55722](https://github.com/frappe/erpnext/issues/55722)) ([09453f8](09453f883b))
* resolve conflict ([271ddb6](271ddb6add))
* restrict already invoiced qty in intercompany purchase invoice ([#55754](https://github.com/frappe/erpnext/issues/55754)) ([a5c23a3](a5c23a3d16))
* **selling:** consider delivered qty (backport [#55597](https://github.com/frappe/erpnext/issues/55597)) ([#55606](https://github.com/frappe/erpnext/issues/55606)) ([e8267e3](e8267e3237))
* simplify New Zealand sales accounts ([eebb37f](eebb37f9fd))
* sql injection ([a94e362](a94e362b8c))
* **stock:** add validation for work order seial nos and batch nos ([6d3f9d3](6d3f9d3c6f))
* update items respects workflow "Only Allow Edit For" role (backport [#55667](https://github.com/frappe/erpnext/issues/55667)) ([#55705](https://github.com/frappe/erpnext/issues/55705)) ([7852ea6](7852ea65af))
* use new_doc with field allowlist in CRM integration endpoints ([45b232d](45b232d369))
* validate fg and materials qty in the disassemble entry ([ba19a24](ba19a24526))
* work order status should be in process if material transfer is skipped (backport [#55641](https://github.com/frappe/erpnext/issues/55641) to version-15-hotfix) ([#55643](https://github.com/frappe/erpnext/issues/55643)) ([55b0715](55b0715310))

### Features

* add New Zealand chart of accounts ([f8a123e](f8a123e79d))

### Performance Improvements

* **transaction:** exit early before backend query (backport [#55556](https://github.com/frappe/erpnext/issues/55556)) ([#55557](https://github.com/frappe/erpnext/issues/55557)) ([ccbca57](ccbca57420))
2026-06-10 00:24:00 +00:00
Mihir Kandoi
316bb13853 Merge pull request #55763 from frappe/version-15-hotfix 2026-06-10 05:52:23 +05:30
mergify[bot]
18ca96c36b fix(process statement of accounts): validate pdf_name and validate permission before triggering send_auto_email (backport #55781) (#55782)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
fix(process statement of accounts): validate pdf_name and validate permission before triggering send_auto_email (#55781)
2026-06-09 19:10:24 +00:00
Mihir Kandoi
95f46dfc01 fix: handle multi-select stock ageing filters (#55776) 2026-06-09 13:45:52 +00:00
mergify[bot]
0e64acb0fa fix: prevent leakage of party-derived fields in cross doctype transactions (backport #55336) (#55578)
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
Co-authored-by: Lakshit Jain <ljain112@gmail.com>
fix: prevent leakage of party-derived fields in cross doctype transactions (#55336)
2026-06-09 13:23:49 +00:00
Pandiyan P
a5c23a3d16 fix: restrict already invoiced qty in intercompany purchase invoice (#55754) 2026-06-09 13:13:23 +05:30
mergify[bot]
2f5b93e308 fix(cheque_print_template): print format creation from cheque print template requires system manager (backport #55708) (#55711)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
fix(cheque_print_template): print format creation from cheque print template requires system manager (#55708)
2026-06-09 13:01:40 +05:30
rohitwaghchaure
aeed6a459f Merge pull request #55751 from frappe/mergify/bp/version-15-hotfix/pr-55748
fix: sql injection (backport #55748)
2026-06-09 09:49:16 +05:30
rohitwaghchaure
c0f56cd284 chore: fix conflicts
Removed conflicting code related to date_field assignment based on filters.
2026-06-09 09:31:58 +05:30
Rohit Waghchaure
a94e362b8c fix: sql injection
(cherry picked from commit bd0acf4413)

# Conflicts:
#	erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py
2026-06-09 03:58:13 +00:00
rohitwaghchaure
a121048f9b Merge pull request #55741 from frappe/mergify/bp/version-15-hotfix/pr-55740
fix: validate fg and materials qty in the disassemble entry (backport #55740)
2026-06-08 22:45:45 +05:30
mergify[bot]
cedd0a1903 fix: bypass project permission check when updating consumed material … (backport #55645) (#55706)
* fix: bypass project permission check when updating consumed material cost

(cherry picked from commit 4b0b7adeee)

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

* chore: resolve conflicts

* chore: resolve conflicts

---------

Co-authored-by: pandiyan <pandiyanpalani37@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-06-08 14:22:54 +00:00
rohitwaghchaure
8bccc25b1a chore: fix conflicts
Removed unused test cases related to sample retention stock entry.
2026-06-08 19:45:39 +05:30
rohitwaghchaure
335173c5ba chore: fix conflicts
Reintroduce the _qty_tolerance function to handle float rounding.
2026-06-08 19:44:48 +05:30
Rohit Waghchaure
ba19a24526 fix: validate fg and materials qty in the disassemble entry
(cherry picked from commit 4453c1072a)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.py
#	erpnext/stock/doctype/stock_entry/test_stock_entry.py
2026-06-08 14:01:31 +00:00
rohitwaghchaure
2c4b89d1df Merge pull request #55734 from frappe/mergify/bp/version-15-hotfix/pr-55716
fix: do not allow to make changes in SABB after submit (backport #55716)
2026-06-08 16:02:35 +05:30
ruthra kumar
14bafe9dfd Merge pull request #55728 from frappe/mergify/bp/version-15-hotfix/pr-55486
Validations in CRM-api endpoints (backport #55486)
2026-06-08 15:50:23 +05:30
ruthra kumar
77d9849d16 Merge pull request #55730 from frappe/mergify/bp/version-15-hotfix/pr-55487
fix: add validations in accounts whitelisted methods (backport #55487)
2026-06-08 15:49:34 +05:30
mergify[bot]
09453f883b fix: remove item name from update items dialog item code column (backport #55718) (#55722)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
Co-authored-by: Abdullah <frappe@LAPTOP-4E788RM4.localdomain>
fix: remove item name from update items dialog item code column (#55718)
2026-06-08 15:42:09 +05:30
rohitwaghchaure
dc1fb3f804 chore: fix conflicts
Removed unused methods related to stock ledger entries and negative stock handling.
2026-06-08 15:28:44 +05:30
Rohit Waghchaure
07b61113af fix: do not allow to make changes in SABB after submit
(cherry picked from commit e36426e235)

# Conflicts:
#	erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
2026-06-08 09:56:01 +00:00
Shllokkk
2d1c0dcb53 fix: add permission checks in accounts whitelisted methods
(cherry picked from commit 5dbf3fdde0)

# Conflicts:
#	erpnext/accounts/doctype/payment_entry/payment_entry.py
2026-06-08 15:17:26 +05:30
Shllokkk
45b232d369 fix: use new_doc with field allowlist in CRM integration endpoints
(cherry picked from commit e460e83516)
2026-06-08 09:41:05 +00:00
mergify[bot]
7852ea65af fix: update items respects workflow "Only Allow Edit For" role (backport #55667) (#55705)
* fix: update items respects workflow "Only Allow Edit For" role (#55667)

(cherry picked from commit 76b9b6a34e)

# Conflicts:
#	erpnext/controllers/accounts_controller.py

* chore: resolve conflicts

---------

Co-authored-by: kaulith <64089478+kaulith@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-06-08 06:59:05 +00:00
ruthra kumar
999cfef619 Merge pull request #55703 from frappe/mergify/bp/version-15-hotfix/pr-55665
fix: drop ignore_permissions handling from add_ac (backport #55665)
2026-06-08 12:01:48 +05:30
Shllokkk
2d0e3fd9af fix: drop ignore_permissions handling from add_ac
(cherry picked from commit 37d2adc74b)
2026-06-08 06:14:59 +00:00
mergify[bot]
4c9c2911a6 chore: remove unused whitelisted method from project (backport #55648) (#55672)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-07 23:15:42 +05:30
rohitwaghchaure
e0a0b3eafc Merge pull request #55663 from frappe/mergify/bp/version-15-hotfix/pr-55661
fix: naming series issue (backport #55661)
2026-06-05 21:04:00 +05:30
rohitwaghchaure
8c9168595f chore: fix conflicts
Removed unused import of DateTimeLikeObject.
2026-06-05 20:48:19 +05:30
Rohit Waghchaure
e7eaa87a77 fix: naming series issue
(cherry picked from commit 3a50056968)

# Conflicts:
#	erpnext/stock/doctype/batch/batch.py
2026-06-05 15:14:02 +00:00
Mihir Kandoi
ffb36cff78 [codex] Show in-transit status for add-to-transit Stock Entries (backport #55644 to version-15-hotfix) (#55659)
[codex] Show in-transit status for add-to-transit Stock Entries (#55644)

(cherry picked from commit df03524b19)
2026-06-05 11:32:33 +00:00
Khushi Rawat
4fadc4aab6 Merge pull request #55652 from khushi8112/address-contacts-report-field-order
fix: correct field order in Address and Contacts report
2026-06-05 16:13:24 +05:30
khushi8112
bde46cffd0 fix: correct field order in Address and Contacts report 2026-06-05 15:33:39 +05:30
khushi8112
08f3cf98f9 fix: correct field order in Address and Contacts report 2026-06-05 15:31:43 +05:30
Mihir Kandoi
55b0715310 fix: work order status should be in process if material transfer is skipped (backport #55641 to version-15-hotfix) (#55643)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:36:10 +00:00
mergify[bot]
c15012cd51 fix: prevent selling items from sample retention warehouse (backport #55613) (#55633)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: prevent selling items from sample retention warehouse (#55613)
2026-06-04 16:48:59 +00:00
Mihir Kandoi
e8e0514a30 fix: persist main item code for MR plan items (#55623) 2026-06-04 21:38:35 +05:30
mergify[bot]
ddaf75a60d refactor: convert rfq_transaction_list to query builder (backport #55497) (#55629)
* refactor: convert rfq_transaction_list to query builder (#55497)

(cherry picked from commit 9cecf2e6f9)

# Conflicts:
#	erpnext/controllers/website_list_for_contact.py

* chore: resolve conflicts

---------

Co-authored-by: Shllokkk <140623894+Shllokkk@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-06-04 14:26:49 +00:00
mergify[bot]
013bd1a566 fix: duplicating a Customer/Supplier shouldn't inherit the source's primary contact and address (backport #55421) (#55608)
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
Co-authored-by: Antoine Maas <antoine.maas@okte.io>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: duplicating a Customer/Supplier shouldn't inherit the source's primary contact and address (#55421)
2026-06-03 16:04:25 +00:00
mergify[bot]
e8267e3237 fix(selling): consider delivered qty (backport #55597) (#55606)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
fix(selling): consider delivered qty (#55597)
2026-06-03 15:37:00 +00:00
rohitwaghchaure
d5477b096d Merge pull request #55604 from aerele/backport-54785
fix(stock): add validation for work order seial nos and batch nos
2026-06-03 19:12:49 +05:30
pandiyan
6d3f9d3c6f fix(stock): add validation for work order seial nos and batch nos 2026-06-03 18:39:22 +05:30
Khushi Rawat
f65b56d73f Merge pull request #55587 from frappe/mergify/bp/version-15-hotfix/pr-55150
fix(accounts): include asset items in purchase receipt validation (backport #55150)
2026-06-03 15:35:52 +05:30
Khushi Rawat
271ddb6add fix: resolve conflict 2026-06-03 14:22:59 +05:30
mergify[bot]
9095c5a3c2 regional(setup): add 0% and 6% VAT rates for Belgium (backport #54719) (#55582)
Co-authored-by: Antoine Maas <antoine.maas@okte.io>
2026-06-03 14:11:51 +05:30
mergify[bot]
0a7c3581da Avoid status updation for purchase invoice from paid to unpaid by issuing a paid debit note against it (backport #54382) (#55575)
Co-authored-by: Shllokkk <140623894+Shllokkk@users.noreply.github.com>
2026-06-03 14:11:12 +05:30
Pandiyan P
611849f953 fix(accounts): include asset items in purchase receipt validation (#55150)
(cherry picked from commit d0d9411700)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
2026-06-03 07:43:14 +00:00
ruthra kumar
5a80de43fa Merge pull request #55478 from IMS94/feat-new-zealand-chart-of-accounts-v15
feat: add New Zealand chart of accounts
2026-06-03 11:17:01 +05:30
mergify[bot]
4772799db2 fix(item): format integer numeric variant attributes without decimals (backport #55561) (#55563)
* fix(item): format integer numeric variant attributes without decimals (#55561)

(cherry picked from commit 016b64df6d)

# Conflicts:
#	erpnext/stock/doctype/item/item.js

* chore: resolve conflicts

---------

Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-06-02 22:59:43 +02:00
mergify[bot]
ccbca57420 perf(transaction): exit early before backend query (backport #55556) (#55557)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-06-02 21:41:47 +02:00
Frappe PR Bot
d6b2fb2f96 chore(release): Bumped to Version 15.110.0
# [15.110.0](https://github.com/frappe/erpnext/compare/v15.109.3...v15.110.0) (2026-06-02)

### Bug Fixes

* billing address does not belongs to the company error ([5c392d6](5c392d6123))
* **book_appointment:** when scheduling is disabled, block API endpoints (backport [#55455](https://github.com/frappe/erpnext/issues/55455)) ([#55456](https://github.com/frappe/erpnext/issues/55456)) ([2a12ae1](2a12ae1afe))
* check perm for account (backport [#55479](https://github.com/frappe/erpnext/issues/55479)) ([#55482](https://github.com/frappe/erpnext/issues/55482)) ([1238aeb](1238aeb30a))
* **issue:** check permission before issue status modification (backport [#55458](https://github.com/frappe/erpnext/issues/55458)) ([#55459](https://github.com/frappe/erpnext/issues/55459)) ([338feb3](338feb31e1))
* **je:** preserve account on duplicate row when party row exists (backport [#55180](https://github.com/frappe/erpnext/issues/55180)) ([#55513](https://github.com/frappe/erpnext/issues/55513)) ([741216d](741216d3eb))
* **manufacturing:** allow to edit batch size while creating a work order ([#55332](https://github.com/frappe/erpnext/issues/55332)) ([41bf2f3](41bf2f32fd))
* material transfer in transit issue (backport [#55320](https://github.com/frappe/erpnext/issues/55320)) ([#55324](https://github.com/frappe/erpnext/issues/55324)) ([067c23f](067c23f20e))
* new bom version should not recalculate operations through routing (backport [#55370](https://github.com/frappe/erpnext/issues/55370)) ([#55371](https://github.com/frappe/erpnext/issues/55371)) ([4669ff2](4669ff295f))
* pick correct name when creating user from RFQ (backport [#55468](https://github.com/frappe/erpnext/issues/55468)) ([#55471](https://github.com/frappe/erpnext/issues/55471)) ([e429e60](e429e608c2))
* **pos:** escape html output in pos page templates (backport [#55527](https://github.com/frappe/erpnext/issues/55527)) ([#55528](https://github.com/frappe/erpnext/issues/55528)) ([689a3f5](689a3f50ae))
* **pos:** escape item data on pos item selector (backport [#55503](https://github.com/frappe/erpnext/issues/55503)) ([#55523](https://github.com/frappe/erpnext/issues/55523)) ([96bd97d](96bd97dd6d))
* **pos:** preserve contacts and enforce permissions in set_customer_info (backport [#55463](https://github.com/frappe/erpnext/issues/55463)) ([#55465](https://github.com/frappe/erpnext/issues/55465)) ([0353262](03532624b8))
* **ppr:** make default_advance_account optional ([aa94c3f](aa94c3ff22))
* **quotation:** made customer contact column visible (backport [#55433](https://github.com/frappe/erpnext/issues/55433)) ([#55434](https://github.com/frappe/erpnext/issues/55434)) ([a2d924c](a2d924c48f))
* **regional:** Japanese CT Rate (backport [#54998](https://github.com/frappe/erpnext/issues/54998)) ([#55437](https://github.com/frappe/erpnext/issues/55437)) ([2a52ea6](2a52ea6850))
* replace get_query with get_list for permission-aware queries in v15 ([ad511b8](ad511b80c0))
* stock reco for legacy serial nos ([93dcba4](93dcba40ec))
* **stock:** add warning message to notify the user to configure the inspection ([42e2fd5](42e2fd5fc9))
* **stock:** allow to create quality inspection after purchase/delivery ([10664b7](10664b7b95))
* **stock:** change qb to qb get_query to fix filter issues (backport [#55443](https://github.com/frappe/erpnext/issues/55443)) ([#55444](https://github.com/frappe/erpnext/issues/55444)) ([75d00ef](75d00ef173))
* **stock:** change valuation rate column label in stock ledger entry/report (backport [#55323](https://github.com/frappe/erpnext/issues/55323)) ([#55393](https://github.com/frappe/erpnext/issues/55393)) ([94fd15e](94fd15e550))
* **stock:** get_actual_qty during cancellations (backport [#55388](https://github.com/frappe/erpnext/issues/55388)) ([#55391](https://github.com/frappe/erpnext/issues/55391)) ([ad6e3a4](ad6e3a45d2))
* update default_advance_account type ([7200c22](7200c22890))
* use get_query instead of get_all for data fetching ([264433b](264433b23d))

### Features

* **payment-entry:** warn user before cancelling reconciled payment entry ([87c6ad4](87c6ad4f85))
2026-06-02 16:55:43 +00:00
Mihir Kandoi
a6b7142c18 Merge pull request #55546 from frappe/version-15-hotfix 2026-06-02 22:24:03 +05:30
Khushi Rawat
6796617921 Merge pull request #55542 from frappe/mergify/bp/version-15-hotfix/pr-55539
feat(payment-entry): warn user before cancelling reconciled payment entry (backport #55539)
2026-06-02 17:09:59 +05:30
ruthra kumar
c65d768020 Merge pull request #55544 from frappe/mergify/bp/version-15-hotfix/pr-54979
fix(ppr): make default_advance_account optional (backport #54979)
2026-06-02 16:48:42 +05:30
Dany Robert
7200c22890 fix: update default_advance_account type
(cherry picked from commit 30b9e11303)
2026-06-02 10:06:40 +00:00
Dany Robert
aa94c3ff22 fix(ppr): make default_advance_account optional
(cherry picked from commit 4b1d369ac6)
2026-06-02 10:06:39 +00:00
khushi8112
87c6ad4f85 feat(payment-entry): warn user before cancelling reconciled payment entry
(cherry picked from commit f0ba54d957)
2026-06-02 10:00:11 +00:00
Khushi Rawat
35b4ada3e2 Merge pull request #55358 from frappe/mergify/bp/version-15-hotfix/pr-55137
fix: use get_query instead of get_all for data fetching (backport #55137)
2026-06-02 13:19:11 +05:30
khushi8112
ad511b80c0 fix: replace get_query with get_list for permission-aware queries in v15 2026-06-02 12:17:37 +05:30
rohitwaghchaure
ad55c7c372 Merge pull request #55518 from frappe/mergify/bp/version-15-hotfix/pr-55415
fix(stock): allow to create quality inspection after purchase/delivery (backport #55415)
2026-06-02 11:52:05 +05:30
Diptanil Saha
689a3f50ae fix(pos): escape html output in pos page templates (backport #55527) (#55528)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 00:42:59 +05:30
Diptanil Saha
96bd97dd6d fix(pos): escape item data on pos item selector (backport #55503) (#55523) 2026-06-01 22:17:44 +05:30
rohitwaghchaure
897722c35f chore: fixed conflicts 2026-06-01 22:14:46 +05:30
rohitwaghchaure
54cbc91166 chore: fixed conflicts 2026-06-01 22:11:58 +05:30
mergify[bot]
ecf9aa146c chore(serial_and_batch_bundle): remove update_serial_or_batch method (backport #55481) (#55515)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-06-01 16:40:41 +00:00
Sudharsanan11
42e2fd5fc9 fix(stock): add warning message to notify the user to configure the inspection
(cherry picked from commit e003fe4de0)

# Conflicts:
#	erpnext/public/js/controllers/transaction.js
2026-06-01 16:13:51 +00:00
Sudharsanan11
10664b7b95 fix(stock): allow to create quality inspection after purchase/delivery
(cherry picked from commit c6a88ab1d2)

# Conflicts:
#	erpnext/controllers/stock_controller.py
#	erpnext/public/js/controllers/transaction.js
2026-06-01 16:13:50 +00:00
ruthra kumar
1980307048 Merge pull request #55514 from ruthra-kumar/remove_flaky_process_pcv_test
refactor(test): remove flaky test in process pcv
2026-06-01 19:35:07 +05:30
Frappe PR Bot
13eeddd1f6 chore(release): Bumped to Version 15.109.3
## [15.109.3](https://github.com/frappe/erpnext/compare/v15.109.2...v15.109.3) (2026-06-01)

### Bug Fixes

* only consider non-opening balance for Balance sheet accounts ([4a6af25](4a6af25d11))
2026-06-01 14:01:43 +00:00
ruthra kumar
dc08b615f1 Merge pull request #55501 from frappe/mergify/bp/version-15/pr-55495
fix: opening bal double counting in Process Period Closing Voucher (backport #55495)
2026-06-01 19:30:03 +05:30
ruthra kumar
ce94f4fd11 refactor(test): remove flaky test in process pcv 2026-06-01 19:15:50 +05:30
ruthra kumar
e314d0cfc5 refactor: color coded status in list view
(cherry picked from commit cfeffbb354)
2026-06-01 19:13:36 +05:30
mergify[bot]
741216d3eb fix(je): preserve account on duplicate row when party row exists (backport #55180) (#55513)
fix(je): preserve account on duplicate row when party row exists (#55180)

(cherry picked from commit 57dbac712f)

Co-authored-by: Gajendra Nishad <75714258+gajjug004@users.noreply.github.com>
2026-06-01 18:46:21 +05:30
ruthra kumar
94e15ae9ef refactor: tabbed view for process period closing voucher
(cherry picked from commit 1960c81619)
2026-06-01 18:09:46 +05:30
ruthra kumar
4a6af25d11 fix: only consider non-opening balance for Balance sheet accounts
(cherry picked from commit a2b8334046)
2026-06-01 18:09:41 +05:30
ruthra kumar
c7fbc133e6 Merge pull request #55498 from frappe/mergify/bp/version-15-hotfix/pr-55495
fix: opening bal double counting in Process Period Closing Voucher (backport #55495)
2026-06-01 17:50:32 +05:30
ruthra kumar
e5aa45cf0d test: prevent double counting of opening balances
(cherry picked from commit 7f2af123ee)
2026-06-01 17:50:10 +05:30
ruthra kumar
3e3689d938 refactor: color coded status in list view
(cherry picked from commit cfeffbb354)
2026-06-01 15:10:00 +05:30
ruthra kumar
d0fc3f029f refactor: tabbed view for process period closing voucher
(cherry picked from commit 1960c81619)
2026-06-01 15:09:57 +05:30
ruthra kumar
a9cfa22199 refactor: only consider non-opening balance for Balance sheet accounts
(cherry picked from commit a2b8334046)
2026-06-01 09:32:45 +00:00
mergify[bot]
1238aeb30a fix: check perm for account (backport #55479) (#55482)
fix: check perm for account (#55479)

(cherry picked from commit dd1d2925d5)

Co-authored-by: Ankush Menat <ankush@frappe.io>
2026-06-01 14:29:35 +05:30
Imesha Sudasingha
eebb37f9fd fix: simplify New Zealand sales accounts 2026-06-01 13:22:06 +05:30
Imesha Sudasingha
f8a123e79d feat: add New Zealand chart of accounts 2026-06-01 12:03:59 +05:30
Frappe PR Bot
1c5220b86f chore(release): Bumped to Version 15.109.2
## [15.109.2](https://github.com/frappe/erpnext/compare/v15.109.1...v15.109.2) (2026-06-01)

### Bug Fixes

* billing address does not belongs to the company error ([c2063c4](c2063c4707))
2026-06-01 06:08:30 +00:00
rohitwaghchaure
779f1b6104 Merge pull request #55474 from frappe/mergify/bp/version-15/pr-55424
fix: billing address does not belongs to the company error (backport #55417) (backport #55424)
2026-06-01 11:36:58 +05:30
Rohit Waghchaure
c2063c4707 fix: billing address does not belongs to the company error
(cherry picked from commit 9df07b367a)
(cherry picked from commit 5c392d6123)
2026-06-01 06:01:45 +00:00
mergify[bot]
e429e608c2 fix: pick correct name when creating user from RFQ (backport #55468) (#55471)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: pick correct name when creating user from RFQ (#55468)
2026-06-01 05:52:40 +00:00
mergify[bot]
75d00ef173 fix(stock): change qb to qb get_query to fix filter issues (backport #55443) (#55444)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
fix(stock): change qb to qb get_query to fix filter issues (#55443)
2026-06-01 05:33:25 +00:00
mergify[bot]
94fd15e550 fix(stock): change valuation rate column label in stock ledger entry/report (backport #55323) (#55393)
Co-authored-by: Sudharsanan11 <sudharsananashok1975@gmail.com>
2026-06-01 05:10:27 +00:00
mergify[bot]
03532624b8 fix(pos): preserve contacts and enforce permissions in set_customer_info (backport #55463) (#55465)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
fix(pos): preserve contacts and enforce permissions in set_customer_info (#55463)
2026-06-01 05:18:34 +05:30
mergify[bot]
338feb31e1 fix(issue): check permission before issue status modification (backport #55458) (#55459)
* fix(issue): check permission before issue status modification (#55458)

(cherry picked from commit 876f403500)

# Conflicts:
#	erpnext/support/doctype/issue/issue.py

* chore: resolve conflicts

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-05-31 19:06:01 +00:00
mergify[bot]
2a805e090c refactor: task_info portal pages (backport #55448) (#55453)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-05-31 22:07:01 +05:30
mergify[bot]
2a12ae1afe fix(book_appointment): when scheduling is disabled, block API endpoints (backport #55455) (#55456)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
fix(book_appointment): when scheduling is disabled, block API endpoints (#55455)
2026-05-31 16:06:25 +00:00
mergify[bot]
715ca39abc refactor(pos_profile): migrating raw sql to qb in set_defaults (backport #55447) (#55449)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-05-31 09:40:26 +00:00
Raffael Meyer
cad14ac3e6 chore: mark as out of beta (backport #55439) (#55440) 2026-05-30 19:09:45 +00:00
mergify[bot]
2a52ea6850 fix(regional): Japanese CT Rate (backport #54998) (#55437)
Co-authored-by: mh35 <mh35jp@gmail.com>
fix(regional): Japanese CT Rate (#54998)
2026-05-30 22:00:19 +05:30
mergify[bot]
a2d924c48f fix(quotation): made customer contact column visible (backport #55433) (#55434)
* fix(quotation): made customer contact column visible (#55433)

(cherry picked from commit 9758eb868d)

# Conflicts:
#	erpnext/selling/doctype/quotation/quotation.json

* chore: resolved conflicts

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-05-30 14:20:40 +00:00
rohitwaghchaure
3ad39c987b Merge pull request #55424 from frappe/mergify/bp/version-15-hotfix/pr-55417
fix: billing address does not belongs to the company error (backport #55417)
2026-05-30 12:43:31 +05:30
Rohit Waghchaure
5c392d6123 fix: billing address does not belongs to the company error
(cherry picked from commit 9df07b367a)
2026-05-29 17:23:55 +00:00
mergify[bot]
ad6e3a45d2 fix(stock): get_actual_qty during cancellations (backport #55388) (#55391)
Co-authored-by: archielister <archie.lister@lush.co.uk>
fix(stock): get_actual_qty during cancellations (#55388)
2026-05-28 22:40:11 +05:30
mergify[bot]
4669ff295f fix: new bom version should not recalculate operations through routing (backport #55370) (#55371)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
fix: new bom version should not recalculate operations through routing (#55370)
2026-05-28 16:01:36 +05:30
khushi8112
264433b23d fix: use get_query instead of get_all for data fetching
(cherry picked from commit 1fd99337b3)
2026-05-27 19:17:02 +00:00
Pandiyan P
41bf2f32fd fix(manufacturing): allow to edit batch size while creating a work order (#55332) 2026-05-27 18:39:59 +05:30
rohitwaghchaure
a6d4bc5c86 Merge pull request #55298 from frappe/mergify/bp/version-15-hotfix/pr-55242
fix: stock reco for legacy serial nos (backport #55242)
2026-05-27 12:15:19 +05:30
Rohit Waghchaure
93dcba40ec fix: stock reco for legacy serial nos
(cherry picked from commit 9d5fd11bcd)
2026-05-27 11:50:30 +05:30
mergify[bot]
067c23f20e fix: material transfer in transit issue (backport #55320) (#55324)
Co-authored-by: Rohit Waghchaure <rohitw1991@gmail.com>
2026-05-27 11:45:23 +05:30
90 changed files with 2283 additions and 1763 deletions

View File

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

View File

@@ -517,6 +517,7 @@ def get_account_autoname(account_number, account_name, company):
def update_account_number(name, account_name, account_number=None, from_descendant=False):
_ensure_idle_system()
account = frappe.get_cached_doc("Account", name)
account.check_permission("write")
if not account:
return

View File

@@ -0,0 +1,449 @@
{
"country_code": "nz",
"name": "New Zealand - Chart of Accounts with Account Numbers",
"disabled": "No",
"tree": {
"Application of Funds (Assets)": {
"Current Assets": {
"Bank Accounts": {
"Business Transaction Account": {
"account_number": "11011",
"account_type": "Bank"
},
"Business Savings Account": {
"account_number": "11012",
"account_type": "Bank"
},
"account_number": "11010",
"is_group": 1
},
"Cash on Hand": {
"account_number": "11020",
"account_type": "Cash"
},
"Accounts Receivable": {
"Debtors": {
"account_number": "11210",
"account_type": "Receivable"
},
"Provision for Doubtful Debts": {
"account_number": "11220"
},
"account_number": "11200",
"is_group": 1
},
"Inventory": {
"Stock on Hand": {
"account_number": "11311",
"account_type": "Stock"
},
"Work In Progress": {
"account_number": "11312",
"account_type": "Stock"
},
"account_number": "11310",
"account_type": "Stock",
"is_group": 1
},
"Prepayments": {
"Prepayments": {
"account_number": "11411"
},
"Supplier Advances": {
"account_number": "11412"
},
"Deferred Expense": {
"account_number": "11413"
},
"account_number": "11410",
"is_group": 1
},
"GST Receivable": {
"account_number": "11510",
"account_type": "Tax"
},
"Income Tax Receivable": {
"account_number": "11520",
"account_type": "Tax"
},
"account_number": "11000",
"is_group": 1
},
"Fixed Assets": {
"Plant & Equipment": {
"Plant & Equipment": {
"account_number": "16011",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Plant & Equipment": {
"account_number": "16012",
"account_type": "Accumulated Depreciation"
},
"account_number": "16010",
"is_group": 1
},
"Motor Vehicles": {
"Motor Vehicles": {
"account_number": "16021",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Motor Vehicles": {
"account_number": "16022",
"account_type": "Accumulated Depreciation"
},
"account_number": "16020",
"is_group": 1
},
"Office Equipment": {
"Office Equipment": {
"account_number": "16031",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Office Equipment": {
"account_number": "16032",
"account_type": "Accumulated Depreciation"
},
"account_number": "16030",
"is_group": 1
},
"Buildings": {
"Buildings": {
"account_number": "16041",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Buildings": {
"account_number": "16042",
"account_type": "Accumulated Depreciation"
},
"account_number": "16040",
"is_group": 1
},
"Computer Equipment": {
"Computer Equipment": {
"account_number": "16051",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Computer Equipment": {
"account_number": "16052",
"account_type": "Accumulated Depreciation"
},
"account_number": "16050",
"is_group": 1
},
"Capital Work in Progress": {
"account_number": "16090",
"account_type": "Capital Work in Progress"
},
"account_number": "16000",
"is_group": 1
},
"account_number": "10000",
"root_type": "Asset"
},
"Source of Funds (Liabilities)": {
"Current Liabilities": {
"Accounts Payable": {
"Creditors": {
"account_number": "21010",
"account_type": "Payable"
},
"account_number": "21000",
"is_group": 1
},
"Goods Received Not Invoiced": {
"account_number": "21100",
"account_type": "Stock Received But Not Billed"
},
"Asset Received Not Invoiced": {
"account_number": "21110",
"account_type": "Asset Received But Not Billed"
},
"Service Received Not Invoiced": {
"account_number": "21120",
"account_type": "Service Received But Not Billed"
},
"Accrued Expenses": {
"account_number": "21200"
},
"Wages Payable": {
"account_number": "21300"
},
"PAYE Payable": {
"account_number": "22010"
},
"KiwiSaver Payable": {
"account_number": "22020"
},
"ACC Payable": {
"account_number": "22030"
},
"Credit Cards": {
"Business Credit Card": {
"account_number": "22110"
},
"account_number": "22100",
"is_group": 1
},
"Customer Advances": {
"account_number": "22200"
},
"Deferred Revenue": {
"account_number": "22210"
},
"Provisional Account": {
"account_number": "22220"
},
"Tax Liabilities": {
"GST Payable": {
"account_number": "22310",
"account_type": "Tax"
},
"GST Suspense": {
"account_number": "22320",
"account_type": "Tax"
},
"FBT Payable": {
"account_number": "22330",
"account_type": "Tax"
},
"Income Tax Payable": {
"account_number": "22340",
"account_type": "Tax"
},
"account_number": "22300",
"is_group": 1
},
"account_number": "21500",
"is_group": 1
},
"Non-Current Liabilities": {
"Bank Loans": {
"Bank Loan": {
"account_number": "25011"
},
"account_number": "25010",
"is_group": 1
},
"Lease Liabilities": {
"Lease Liability": {
"account_number": "25021"
},
"account_number": "25020",
"is_group": 1
},
"Shareholder Loans": {
"Shareholder Loan": {
"account_number": "25031"
},
"account_number": "25030",
"is_group": 1
},
"account_number": "25000",
"is_group": 1
},
"account_number": "20000",
"root_type": "Liability"
},
"Equity": {
"Share Capital": {
"account_number": "31010",
"account_type": "Equity"
},
"Drawings": {
"account_number": "31020",
"account_type": "Equity"
},
"Current Year Earnings": {
"account_number": "35010",
"account_type": "Equity"
},
"Retained Earnings": {
"account_number": "35020",
"account_type": "Equity"
},
"account_number": "30000",
"root_type": "Equity"
},
"Income": {
"Sales": {
"account_number": "41010",
"account_type": "Income Account"
},
"Other Income": {
"Interest Income": {
"account_number": "47010",
"account_type": "Income Account"
},
"Rounding Gain/Loss": {
"account_number": "47020",
"account_type": "Income Account"
},
"Foreign Exchange Gain": {
"account_number": "47030",
"account_type": "Income Account"
},
"account_number": "47000",
"is_group": 1
},
"account_number": "40000",
"root_type": "Income"
},
"Expenses": {
"Cost of Goods Sold": {
"Purchases": {
"account_number": "51010",
"account_type": "Cost of Goods Sold"
},
"Freight Inwards": {
"account_number": "51020",
"account_type": "Expenses Included In Valuation"
},
"Duty and Landing Costs": {
"account_number": "51030",
"account_type": "Expenses Included In Valuation"
},
"Stock Adjustment": {
"account_number": "51040",
"account_type": "Stock Adjustment"
},
"Stock Write Off": {
"account_number": "51050",
"account_type": "Stock Adjustment"
},
"account_number": "51000",
"account_type": "Cost of Goods Sold",
"is_group": 1
},
"Operating Expenses": {
"Wages & Salaries": {
"account_number": "61010",
"account_type": "Expense Account"
},
"KiwiSaver Employer Contribution": {
"account_number": "61020",
"account_type": "Expense Account"
},
"ACC Levies": {
"account_number": "61030",
"account_type": "Expense Account"
},
"Rent": {
"account_number": "65010",
"account_type": "Expense Account"
},
"Power": {
"account_number": "65020",
"account_type": "Expense Account"
},
"Telephone": {
"account_number": "66010",
"account_type": "Expense Account"
},
"Insurance": {
"account_number": "64010",
"account_type": "Expense Account"
},
"Accounting Fees": {
"account_number": "64020",
"account_type": "Expense Account"
},
"Legal Fees": {
"account_number": "64030",
"account_type": "Expense Account"
},
"Advertising and Marketing": {
"account_number": "65030",
"account_type": "Expense Account"
},
"Repairs and Maintenance": {
"account_number": "65040",
"account_type": "Expense Account"
},
"Freight and Courier": {
"account_number": "65050",
"account_type": "Expense Account"
},
"Operating Costs": {
"account_number": "65060",
"account_type": "Expense Account"
},
"account_number": "60000",
"is_group": 1
},
"Depreciation and Amortisation": {
"Depreciation - Plant & Equipment": {
"account_number": "62010",
"account_type": "Depreciation"
},
"Depreciation - Motor Vehicles": {
"account_number": "62020",
"account_type": "Depreciation"
},
"Depreciation - Office Equipment": {
"account_number": "62030",
"account_type": "Depreciation"
},
"Depreciation - Computer Equipment": {
"account_number": "62040",
"account_type": "Depreciation"
},
"account_number": "62000",
"is_group": 1
},
"Finance Costs": {
"Bank Charges": {
"account_number": "67010",
"account_type": "Expense Account"
},
"Interest Expense": {
"account_number": "67020",
"account_type": "Expense Account"
},
"Rounding Off": {
"account_number": "67030",
"account_type": "Round Off"
},
"Payment Discounts": {
"account_number": "67040",
"account_type": "Expense Account"
},
"account_number": "67000",
"is_group": 1
},
"Income Tax Expense": {
"account_number": "81010",
"account_type": "Expense Account"
},
"Foreign Exchange": {
"Exchange Gain/Loss": {
"account_number": "82010",
"account_type": "Expense Account"
},
"Unrealized Exchange Gain/Loss": {
"account_number": "82020",
"account_type": "Expense Account"
},
"account_number": "82000",
"is_group": 1
},
"Bad Debts": {
"account_number": "83010",
"account_type": "Expense Account"
},
"Write Off": {
"account_number": "83020",
"account_type": "Expense Account"
},
"Gain/Loss on Asset Disposal": {
"account_number": "83030",
"account_type": "Expense Account"
},
"Expenses Included In Asset Valuation": {
"account_number": "84010",
"account_type": "Expenses Included In Asset Valuation"
},
"account_number": "50000",
"root_type": "Expense"
}
}
}

View File

@@ -1,7 +1,6 @@
{
"actions": [],
"autoname": "format:Bank Statement Import on {creation}",
"beta": 1,
"creation": "2019-08-04 14:16:08.318714",
"doctype": "DocType",
"editable_grid": 1,
@@ -211,10 +210,11 @@
],
"hide_toolbar": 1,
"links": [],
"modified": "2024-06-25 17:32:07.658250",
"modified": "2026-05-30 20:51:10.353723",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -230,7 +230,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print");
frappe.ui.form.on("Cheque Print Template", {
refresh: function (frm) {
if (!frm.doc.__islocal) {
frm.add_custom_button(
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
function () {
erpnext.cheque_print.view_cheque_print(frm);
}
).addClass("btn-primary");
if (frappe.user.has_role("System Manager")) {
frm.add_custom_button(
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
function () {
erpnext.cheque_print.view_cheque_print(frm);
}
).addClass("btn-primary");
}
$(frm.fields_dict.cheque_print_preview.wrapper).empty();

View File

@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
@frappe.whitelist()
def create_or_update_cheque_print_format(template_name):
frappe.only_for("System Manager")
if not frappe.db.exists("Print Format", template_name):
cheque_print = frappe.new_doc("Print Format")
cheque_print.update(

View File

@@ -2,7 +2,6 @@
"actions": [],
"allow_events_in_timeline": 1,
"autoname": "naming_series:",
"beta": 1,
"creation": "2019-07-05 16:34:31.013238",
"doctype": "DocType",
"engine": "InnoDB",
@@ -400,7 +399,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2024-11-26 13:46:07.760867",
"modified": "2026-05-30 20:40:30.851842",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning",
@@ -449,9 +448,10 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "ASC",
"states": [],
"title_field": "customer_name",
"track_changes": 1
}
}

View File

@@ -1,7 +1,6 @@
{
"actions": [],
"allow_rename": 1,
"beta": 1,
"creation": "2019-12-04 04:59:08.003664",
"doctype": "DocType",
"editable_grid": 1,
@@ -107,7 +106,7 @@
"link_fieldname": "dunning_type"
}
],
"modified": "2021-11-13 00:25:35.659283",
"modified": "2026-05-30 20:40:09.952533",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning Type",
@@ -151,7 +150,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -378,15 +378,17 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
accounts_add(doc, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn);
row.exchange_rate = 1;
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
if (!row.exchange_rate) row.exchange_rate = 1;
if (!row.account) {
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
}
// set difference
if (doc.difference) {

View File

@@ -1,6 +1,6 @@
{
"actions": [],
"allow_copy": 1,
"beta": 1,
"creation": "2017-08-29 02:22:54.947711",
"doctype": "DocType",
"editable_grid": 1,
@@ -64,10 +64,10 @@
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"collapsible": 1,
@@ -82,7 +82,8 @@
],
"hide_toolbar": 1,
"issingle": 1,
"modified": "2022-01-04 15:25:06.053187",
"links": [],
"modified": "2026-05-30 20:43:36.282738",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool",
@@ -99,7 +100,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -31,6 +31,7 @@ class OpeningInvoiceCreationTool(Document):
create_missing_party: DF.Check
invoice_type: DF.Literal["Sales", "Purchase"]
invoices: DF.Table[OpeningInvoiceCreationToolItem]
project: DF.Link | None
# end: auto-generated types
def onload(self):

View File

@@ -1773,6 +1773,35 @@ frappe.ui.form.on("Payment Entry", {
},
});
},
before_cancel: function (frm) {
return new Promise((resolve, reject) => {
frappe.call({
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
args: { payment_entry: frm.doc.name },
callback: function (r) {
const linked = r.message || [];
if (!linked.length) {
resolve();
return;
}
const bt_links = linked
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
.join(", ");
frappe.confirm(
__(
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
[bt_links]
),
() => resolve(),
() => reject(),
__("Yes"),
__("No")
);
},
});
});
},
});
frappe.ui.form.on("Payment Entry Reference", {

View File

@@ -2279,6 +2279,9 @@ def get_outstanding_reference_documents(args, validate=False):
if args.get("party_type") == "Member":
return
if args.get("party_type") and args.get("party"):
frappe.has_permission(args["party_type"], "read", args["party"], throw=True)
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
args["get_outstanding_invoices"] = True
@@ -2788,7 +2791,8 @@ def get_reference_details(
):
total_amount = outstanding_amount = exchange_rate = account = None
ref_doc = frappe.get_doc(reference_doctype, reference_name)
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
# Only applies for Reverse Payment Entries
@@ -3599,3 +3603,16 @@ def make_payment_order(source_name, target_doc=None):
@erpnext.allow_regional
def add_regional_gl_entries(gl_entries, doc):
return
@frappe.whitelist()
def get_linked_bank_transactions(payment_entry: str) -> list:
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
return frappe.get_all(
"Bank Transaction Payments",
filters={
"payment_document": "Payment Entry",
"payment_entry": payment_entry,
},
pluck="parent",
)

View File

@@ -202,15 +202,14 @@ class POSProfile(Document):
def set_defaults(self, include_current_pos=True):
frappe.defaults.clear_default("is_pos")
if not include_current_pos:
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
else:
condition = " where pfu.default = 1 "
pfu = frappe.qb.DocType("POS Profile User")
pos_view_users = frappe.db.sql_list(
f"""select pfu.user
from `tabPOS Profile User` as pfu {condition}"""
)
query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
if not include_current_pos:
query = query.where(pfu.name != self.name)
pos_view_users = query.run(as_list=1, pluck=True)
for user in pos_view_users:
if user:

View File

@@ -151,13 +151,13 @@
"label": "Default Advance Account",
"mandatory_depends_on": "doc.party_type",
"options": "Account",
"reqd": 1
"reqd": 0
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-01-08 08:22:14.798085",
"modified": "2026-05-16 11:43:12.758685",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Payment Reconciliation",

View File

@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
bank_cash_account: DF.Link | None
company: DF.Link
cost_center: DF.Link | None
default_advance_account: DF.Link
default_advance_account: DF.Link | None
error_log: DF.LongText | None
from_invoice_date: DF.Date | None
from_payment_date: DF.Date | None
@@ -215,10 +215,7 @@ def trigger_reconciliation_for_queued_docs():
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
def get_filters_as_tuple(fields, doc):
filters = ()
for x in fields:
filters += tuple(doc.get(x))
return filters
return tuple(doc.get(x) or "" for x in fields)
for x in all_queued:
doc = frappe.get_doc("Process Payment Reconciliation", x)

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "format:Process-PCV-{###}",
"creation": "2025-09-25 15:44:03.534699",
"doctype": "DocType",
@@ -7,11 +8,13 @@
"field_order": [
"parent_pcv",
"status",
"amended_from",
"section_normal_balances",
"p_l_closing_balance",
"normal_balances",
"bs_closing_balance",
"z_opening_balances",
"amended_from"
"normal_balances",
"section_opening_balances",
"z_opening_balances"
],
"fields": [
{
@@ -64,17 +67,27 @@
"fieldname": "bs_closing_balance",
"fieldtype": "JSON",
"label": "Balance Sheet Closing Balance"
},
{
"fieldname": "section_normal_balances",
"fieldtype": "Tab Break",
"label": "Normal Balances"
},
{
"fieldname": "section_opening_balances",
"fieldtype": "Tab Break",
"label": "Opening Balances"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-11-05 11:40:24.996403",
"modified": "2026-06-01 12:16:37.374412",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Period Closing Voucher",
"naming_rule": "Expression",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

View File

@@ -559,6 +559,9 @@ def process_individual_date(docname: str, date, report_type, parentfield):
if parentfield == "z_opening_balances":
query = query.where(gle.is_opening.eq("Yes"))
else:
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
query = query.where(gle.is_opening.eq("No"))
query = query.groupby(gle.account)
for dim in dimensions:

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
// render
frappe.listview_settings["Process Period Closing Voucher"] = {
add_fields: ["status"],
get_indicator: function (doc) {
const status_colors = {
Queued: "blue",
Running: "orange",
Paused: "gray",
Completed: "green",
Cancelled: "red",
};
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
},
};

View File

@@ -99,6 +99,7 @@ class ProcessStatementOfAccounts(Document):
validate_template(self.subject)
validate_template(self.body)
validate_template(self.pdf_name)
if not self.customers:
frappe.throw(_("Customers not selected."))
@@ -548,6 +549,7 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
@frappe.whitelist()
def send_auto_email():
frappe.has_permission("Process Statement Of Accounts", throw=True)
selected = frappe.get_list(
"Process Statement Of Accounts",
filters={"enable_auto_email": 1},

View File

@@ -608,6 +608,25 @@ frappe.ui.form.on("Purchase Invoice", {
};
});
frm.set_query("write_off_account", function (doc) {
return {
filters: {
report_type: "Profit and Loss",
is_group: 0,
company: doc.company,
},
};
});
frm.set_query("write_off_cost_center", function (doc) {
return {
filters: {
is_group: 0,
company: doc.company,
},
};
});
frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) {
return {
filters: {

View File

@@ -287,6 +287,7 @@ class PurchaseInvoice(BuyingController):
self.validate_expense_account()
self.set_against_expense_account()
self.validate_write_off_account()
self.validate_write_off_cost_center()
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
self.set_status()
self.validate_purchase_receipt_if_update_stock()
@@ -633,15 +634,16 @@ class PurchaseInvoice(BuyingController):
throw(msg, title=_("Mandatory Purchase Order"))
def pr_required(self):
stock_items = self.get_stock_items()
if frappe.db.get_value("Buying Settings", None, "pr_required") == "Yes":
if frappe.db.get_single_value("Buying Settings", "pr_required") == "Yes":
stock_and_asset_items = self.get_stock_items()
stock_and_asset_items.extend(self.get_asset_items())
if frappe.get_value(
"Supplier", self.supplier, "allow_purchase_invoice_creation_without_purchase_receipt"
):
return
for d in self.get("items"):
if not d.purchase_receipt and d.item_code in stock_items:
if not d.purchase_receipt and d.item_code in stock_and_asset_items:
msg = _("Purchase Receipt Required for item {}").format(frappe.bold(d.item_code))
msg += "<br><br>"
msg += _(
@@ -657,6 +659,27 @@ class PurchaseInvoice(BuyingController):
if self.write_off_amount and not self.write_off_account:
throw(_("Please enter Write Off Account"))
if not self.write_off_account:
return
doc = frappe.db.get_value(
"Account", self.write_off_account, ["report_type", "is_group", "company"], as_dict=True
)
if not doc or doc.report_type != "Profit and Loss" or doc.is_group or doc.company != self.company:
throw(_("Please enter a valid Write Off Account"))
def validate_write_off_cost_center(self):
if not self.write_off_cost_center:
return
doc = frappe.db.get_value(
"Cost Center", self.write_off_cost_center, ["is_group", "company"], as_dict=True
)
if not doc or doc.is_group or doc.company != self.company:
throw(_("Please enter a valid Write Off Cost Center"))
def check_prev_docstatus(self):
for d in self.get("items"):
if d.purchase_order:
@@ -737,6 +760,7 @@ class PurchaseInvoice(BuyingController):
def validate_for_repost(self):
self.validate_write_off_account()
self.validate_write_off_cost_center()
self.validate_expense_account()
validate_docs_for_voucher_types(["Purchase Invoice"])
validate_docs_for_deferred_accounting([], [self.name])
@@ -850,7 +874,9 @@ class PurchaseInvoice(BuyingController):
if update_outstanding == "No":
update_voucher_outstanding(
voucher_type=self.doctype,
voucher_no=self.return_against if cint(self.is_return) and self.return_against else self.name,
voucher_no=self.return_against
if (cint(self.is_return) and self.return_against)
else self.name,
account=self.credit_to,
party_type="Supplier",
party=self.supplier,
@@ -1533,6 +1559,9 @@ class PurchaseInvoice(BuyingController):
def make_payment_gl_entries(self, gl_entries):
# Make Cash GL Entries
if cint(self.is_paid) and self.cash_bank_account and self.paid_amount:
against_voucher = self.name
if self.is_return and self.return_against and not self.update_outstanding_for_self:
against_voucher = self.return_against
bank_account_currency = get_account_currency(self.cash_bank_account)
# CASH, make payment entries
gl_entries.append(
@@ -1547,9 +1576,7 @@ class PurchaseInvoice(BuyingController):
if self.party_account_currency == self.company_currency
else self.paid_amount,
"debit_in_transaction_currency": self.paid_amount,
"against_voucher": self.return_against
if cint(self.is_return) and self.return_against
else self.name,
"against_voucher": against_voucher,
"against_voucher_type": self.doctype,
"cost_center": self.cost_center,
"project": self.project,

View File

@@ -180,12 +180,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
: "Inter Company Purchase Invoice";
me.frm.add_custom_button(
button_label,
__(button_label),
function () {
me.make_inter_company_invoice();
},
__("Create")
);
frappe.call({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.get_received_items",
args: {
reference_name: me.frm.doc.name,
doctype: "Purchase Invoice",
reference_fieldname: "sales_invoice_item",
},
callback: function (r) {
if (r.exc) return;
const received_items = r.message || {};
const has_pending_qty = me.frm.doc.items.some(
(item) => flt(item.qty) - flt(received_items[item.name] || 0) > 0
);
if (!has_pending_qty) {
me.frm.remove_custom_button(__(button_label), __("Create"));
}
},
});
}
}

View File

@@ -23,7 +23,12 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
get_party_tax_withholding_details,
)
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
from erpnext.accounts.party import (
CROSS_PARTY_FIELD_NO_MAP,
get_due_date,
get_party_account,
get_party_details,
)
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
@@ -2526,7 +2531,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
"rate": "rate",
},
"postprocess": update_item,
"condition": lambda doc: doc.qty > 0,
"condition": lambda doc: doc.qty - received_items.get(doc.name, 0.0) > 0,
}
if doctype in ["Sales Invoice", "Sales Order"]:
@@ -2559,18 +2564,25 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
"doctype": target_doctype,
"postprocess": update_details,
"set_target_warehouse": "set_from_warehouse",
"field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"],
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"],
},
doctype + " Item": item_field_map,
},
target_doc,
set_missing_values,
)
if not doclist.get("items"):
frappe.throw(
_(
"Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. "
"Please check the existing linked {2}s."
).format(target_doctype, doctype, target_doctype)
)
return doclist
def get_received_items(reference_name, doctype, reference_fieldname):
@frappe.whitelist()
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
reference_field = "inter_company_invoice_reference"
if doctype == "Purchase Order":
reference_field = "inter_company_order_reference"
@@ -2583,20 +2595,19 @@ def get_received_items(reference_name, doctype, reference_fieldname):
target_doctypes = frappe.get_all(
doctype,
filters=filters,
as_list=True,
pluck="name",
)
received_items_map = {}
if target_doctypes:
target_doctypes = list(target_doctypes[0])
received_items_map = frappe._dict(
frappe.get_all(
received_items_data = frappe.get_all(
doctype + " Item",
filters={"parent": ("in", target_doctypes)},
fields=[reference_fieldname, "qty"],
as_list=1,
)
)
for item in received_items_data:
key = item.get(reference_fieldname)
if key:
received_items_map[key] = received_items_map.get(key, 0.0) + flt(item.qty)
return received_items_map

View File

@@ -2690,6 +2690,95 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(target_doc.company, "_Test Company 1")
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
def test_restrict_inter_company_pi_when_sales_invoice_qty_fully_consumed(self):
item_code_1 = "_Test IC Item 1"
item_code_2 = "_Test IC Item 2"
create_item(item_code_1, is_stock_item=1)
create_item(item_code_2, is_stock_item=1)
si = create_sales_invoice(
company="Wind Power LLC",
customer="_Test Internal Customer",
item_code=item_code_1,
debit_to="Debtors - WP",
warehouse="Stores - WP",
income_account="Sales - WP",
expense_account="Cost of Goods Sold - WP",
cost_center="Main - WP",
currency="USD",
qty=3,
do_not_save=1,
)
si.selling_price_list = "_Test Price List Rest of the World"
si.append(
"items",
{
"item_code": item_code_2,
"item_name": item_code_2,
"description": item_code_2,
"warehouse": "Stores - WP",
"qty": 2,
"uom": "Nos",
"stock_uom": "Nos",
"rate": 100,
"price_list_rate": 100,
"income_account": "Sales - WP",
"expense_account": "Cost of Goods Sold - WP",
"cost_center": "Main - WP",
"conversion_factor": 1,
},
)
si.submit()
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
for item in target_doc.items:
item.update(
{
"expense_account": "Cost of Goods Sold - _TC1",
"cost_center": "Main - _TC1",
}
)
target_doc.submit()
self.assertEqual(len(target_doc.items), 2)
self.assertEqual([item.qty for item in target_doc.items], [3, 2])
with self.assertRaisesRegex(
frappe.ValidationError,
"already been fully invoiced",
):
make_inter_company_transaction("Sales Invoice", si.name)
def test_inter_company_transaction_does_not_inherit_party_fields(self):
"""
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
"""
si = create_sales_invoice(
company="Wind Power LLC",
customer="_Test Internal Customer",
debit_to="Debtors - WP",
warehouse="Stores - WP",
income_account="Sales - WP",
expense_account="Cost of Goods Sold - WP",
cost_center="Main - WP",
currency="USD",
do_not_save=1,
)
si.selling_price_list = "_Test Price List Rest of the World"
si.tax_category = "_Test Tax Category 1"
si.language = "ar"
si.payment_terms_template = "_Test Payment Term Template"
si.submit()
pi = make_inter_company_transaction("Sales Invoice", si.name)
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier")
self.assertEqual(pi.tax_category or None, supplier.tax_category or None)
self.assertEqual(pi.language or None, supplier.language or None)
self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None)
def test_inter_company_transaction_without_default_warehouse(self):
"Check mapping (expense account) of inter company SI to PI in absence of default warehouse."
# setup

View File

@@ -48,6 +48,25 @@ SALES_TRANSACTION_TYPES = {
}
TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES
# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the
# source and target documents belong to different parties (e.g. Sales Order →
# Purchase Order or inter-company Sales Invoice → Purchase Invoice).
CROSS_PARTY_FIELD_NO_MAP = [
"tax_category",
"tax_id",
"tax_withholding_category",
"taxes_and_charges",
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"shipping_address",
"dispatch_address",
"payment_terms_template",
"language",
]
class DuplicatePartyAccountError(frappe.ValidationError):
pass

View File

@@ -4,6 +4,7 @@
import frappe
from frappe import _
from frappe.query_builder import CustomFunction
from frappe.utils import cint
@@ -94,19 +95,35 @@ def get_data(filters):
def get_sales_details(filters):
item_details_map = {}
date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date"
if filters["based_on"] not in ("Sales Order", "Sales Invoice"):
frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"]))
sales_data = frappe.db.sql(
"""
select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date,
DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order
from `tab{doctype}` s, `tab{doctype} Item` si
where s.name = si.parent and s.docstatus = 1
order by days_since_last_order """.format( # nosec
date_field=date_field, doctype=filters["based_on"]
),
as_dict=1,
)
parent = frappe.qb.DocType(filters["based_on"])
child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item"
child = frappe.qb.DocType(child_doctype)
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
current_date = CustomFunction("CURRENT_DATE", [])
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
days_since_last_order = date_diff(current_date(), date_col)
sales_data = (
frappe.qb.from_(parent)
.inner_join(child)
.on(parent.name == child.parent)
.select(
parent.territory,
parent.customer,
child.item_group,
child.item_code,
child.qty,
date_col.as_("last_order_date"),
days_since_last_order.as_("days_since_last_order"),
)
.where(parent.docstatus == 1)
.orderby(days_since_last_order)
).run(as_dict=True)
for d in sales_data:
item_details_map.setdefault((d.territory, d.item_code), d)

View File

@@ -278,6 +278,7 @@ def get_balance_on(
)
if party_type and party:
frappe.has_permission(party_type, "read", party, throw=True)
cond.append(
f"""gle.party_type = {frappe.db.escape(party_type)} and gle.party = {frappe.db.escape(party)} """
)
@@ -397,15 +398,13 @@ def add_ac(args=None):
if not args:
args = frappe.local.form_dict
args.pop("ignore_permissions", None)
frappe.has_permission("Account", "create", throw=True)
args.doctype = "Account"
args = make_tree_args(**args)
ac = frappe.new_doc("Account")
if args.get("ignore_permissions"):
ac.flags.ignore_permissions = True
args.pop("ignore_permissions")
ac.update(args)
if not ac.parent_account:

View File

@@ -6,6 +6,7 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.contact.contact import get_full_name
from frappe.core.doctype.communication.email import make
from frappe.desk.form.load import get_attachments
from frappe.model.mapper import get_mapped_doc
@@ -272,12 +273,20 @@ class RequestforQuotation(BuyingController):
supplier_doc.save()
def create_user(self, rfq_supplier, link):
contact_name = None
if rfq_supplier.contact:
name_fields = frappe.get_value(
"Contact", rfq_supplier.contact, ["first_name", "middle_name", "last_name"]
)
if name_fields:
contact_name = get_full_name(*name_fields)
user = frappe.get_doc(
{
"doctype": "User",
"send_welcome_email": 0,
"email": rfq_supplier.email_id,
"first_name": rfq_supplier.supplier_name or rfq_supplier.supplier,
"first_name": contact_name or rfq_supplier.supplier_name or rfq_supplier.supplier,
"user_type": "Website User",
"redirect_url": link,
}

View File

@@ -362,19 +362,22 @@
"fieldname": "supplier_primary_contact",
"fieldtype": "Link",
"label": "Supplier Primary Contact",
"no_copy": 1,
"options": "Contact"
},
{
"fetch_from": "supplier_primary_contact.mobile_no",
"fieldname": "mobile_no",
"fieldtype": "Read Only",
"label": "Mobile No"
"label": "Mobile No",
"no_copy": 1
},
{
"fetch_from": "supplier_primary_contact.email_id",
"fieldname": "email_id",
"fieldtype": "Read Only",
"label": "Email Id"
"label": "Email Id",
"no_copy": 1
},
{
"fieldname": "column_break_44",
@@ -384,6 +387,7 @@
"fieldname": "primary_address",
"fieldtype": "Text",
"label": "Primary Address",
"no_copy": 1,
"read_only": 1
},
{
@@ -391,6 +395,7 @@
"fieldname": "supplier_primary_address",
"fieldtype": "Link",
"label": "Supplier Primary Address",
"no_copy": 1,
"options": "Address"
},
{
@@ -486,7 +491,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-02-06 12:58:01.398824",
"modified": "2026-05-29 16:52:59.441272",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",

View File

@@ -7,7 +7,7 @@ from collections import defaultdict
import frappe
from frappe import _, bold, qb, throw
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
from frappe.model.workflow import get_workflow_name
from frappe.query_builder import Criterion, DocType
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Sum
@@ -178,7 +178,7 @@ class AccountsController(TransactionBase):
if not get_meta(self.doctype).has_field("outstanding_amount"):
return
if self.get("is_return") and self.return_against and not self.get("is_pos"):
if self.get("is_return") and self.return_against and not (self.get("is_pos") or self.get("is_paid")):
against_voucher_outstanding = frappe.get_value(
self.doctype, self.return_against, "outstanding_amount"
)
@@ -3815,7 +3815,9 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
@frappe.whitelist()
def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"):
def update_child_qty_rate(
parent_doctype: str, trans_items: str, parent_doctype_name: str, child_docname: str = "items"
):
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
from erpnext.selling.doctype.quotation.quotation import get_ordered_items
@@ -3841,14 +3843,12 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
current_state = doc.get(workflow_doc.workflow_state_field)
roles = frappe.get_roles()
transitions = []
for transition in workflow_doc.transitions:
if transition.next_state == current_state and transition.allowed in roles:
if not is_transition_condition_satisfied(transition, doc):
continue
transitions.append(transition.as_dict())
allowed = any(
state.state == current_state and (not state.allow_edit or state.allow_edit in roles)
for state in workflow_doc.states
)
if not transitions:
if not allowed:
frappe.throw(
_("You are not allowed to update as per the conditions set in {} Workflow.").format(
get_link_to_form("Workflow", workflow)

View File

@@ -370,10 +370,14 @@ def get_delivery_notes_to_be_billed(
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
)
query = frappe.qb.get_query(
"Delivery Note",
fields=fields,
filters=filters,
)
query = (
frappe.qb.from_(DeliveryNote)
.select(*[DeliveryNote[f] for f in fields])
.where(
query.where(
(DeliveryNote.docstatus == 1)
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
& (DeliveryNote[searchfield].like(f"%{txt}%"))
@@ -387,12 +391,11 @@ def get_delivery_notes_to_be_billed(
)
)
)
.orderby(DeliveryNote[searchfield], order=Order.asc)
.limit(page_len)
.offset(start)
)
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)

View File

@@ -53,6 +53,7 @@ class SellingController(StockController):
self.validate_for_duplicate_items()
self.validate_target_warehouse()
self.validate_auto_repeat_subscription_dates()
self.validate_sample_retention_warehouse()
for table_field in ["items", "packed_items"]:
if self.get(table_field):
self.set_serial_and_batch_bundle(table_field)
@@ -874,6 +875,26 @@ class SellingController(StockController):
validate_item_type(self, "is_sales_item", "sales")
def validate_sample_retention_warehouse(self):
if self.get("is_return"):
return
sample_retention_warehouse = frappe.db.get_single_value(
"Stock Settings", "sample_retention_warehouse"
)
if not sample_retention_warehouse:
return
items = self.get("items") + (self.get("packed_items"))
for item in items:
if item.get("warehouse") == sample_retention_warehouse:
frappe.throw(
_("Row {0}: Cannot sell item {1} from Sample Retention Warehouse {2}").format(
item.idx, frappe.bold(item.item_code), frappe.bold(sample_retention_warehouse)
),
title=_("Not Allowed"),
)
def update_stock_reservation_entries(self) -> None:
"""Updates Delivered Qty in Stock Reservation Entries."""

View File

@@ -1680,7 +1680,7 @@ def repost_required_for_queue(doc: StockController) -> bool:
@frappe.whitelist()
def check_item_quality_inspection(doctype, items):
def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]):
if isinstance(items, str):
items = json.loads(items)
@@ -1692,13 +1692,30 @@ def check_item_quality_inspection(doctype, items):
"Delivery Note": "inspection_required_before_delivery",
}
items_to_remove = []
for item in items:
if not frappe.db.get_value("Item", item.get("item_code"), inspection_fieldname_map.get(doctype)):
items_to_remove.append(item)
items = [item for item in items if item not in items_to_remove]
inspection_fieldname = inspection_fieldname_map.get(doctype)
if inspection_fieldname is None:
return []
return items
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
)
if allow_after_transaction:
return items
item_codes = list({item.get("item_code") for item in items})
Item = frappe.qb.DocType("Item")
results = (
frappe.qb.from_(Item)
.select(Item.name)
.where((Item.name.isin(item_codes)) & (Item[inspection_fieldname] == 1))
.run(as_dict=True)
)
inspection_required_items = {row.name for row in results}
return [item for item in items if item.get("item_code") in inspection_required_items]
@frappe.whitelist()

View File

@@ -178,13 +178,16 @@ def get_list_for_transactions(
def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_page_length):
data = frappe.db.sql(
"""select distinct parent as name, supplier from `tab{doctype}`
where supplier = '{supplier}' and docstatus=1 order by modified desc limit {start}, {len}""".format(
doctype=parties_doctype, supplier=parties[0], start=limit_start, len=limit_page_length
),
as_dict=1,
)
party = frappe.qb.DocType(parties_doctype)
data = (
frappe.qb.from_(party)
.select(party.parent.as_("name"), party.supplier)
.distinct()
.where((party.supplier == party[0]) & (party.docstatus == 1))
.orderby(party.creation, order=frappe.qb.desc)
.limit(limit_page_length)
.offset(limit_start)
).run(as_dict=True)
return post_process(doctype, data)

View File

@@ -32,20 +32,16 @@ def create_custom_fields_for_frappe_crm():
@frappe.whitelist()
def create_prospect_against_crm_deal():
doc = frappe.form_dict
prospect = frappe.get_doc(
{
"doctype": "Prospect",
"company_name": doc.organization or doc.lead_name,
"no_of_employees": doc.no_of_employees,
"prospect_owner": doc.deal_owner,
"company": doc.erpnext_company,
"crm_deal": doc.crm_deal,
"territory": doc.territory,
"industry": doc.industry,
"website": doc.website,
"annual_revenue": doc.annual_revenue,
}
)
prospect = frappe.new_doc("Prospect")
prospect.company_name = doc.organization or doc.lead_name
prospect.no_of_employees = doc.no_of_employees
prospect.prospect_owner = doc.deal_owner
prospect.company = doc.erpnext_company
prospect.crm_deal = doc.crm_deal
prospect.territory = doc.territory
prospect.industry = doc.industry
prospect.website = doc.website
prospect.annual_revenue = doc.annual_revenue
try:
prospect_name = frappe.db.get_value("Prospect", {"company_name": prospect.company_name})
@@ -151,6 +147,18 @@ def contact_exists(email, mobile_no):
return False
CUSTOMER_ALLOWED_FIELDS = {
"customer_name",
"customer_group",
"customer_type",
"territory",
"default_currency",
"industry",
"website",
"crm_deal",
}
@frappe.whitelist()
def create_customer(customer_data=None):
if not customer_data:
@@ -159,9 +167,11 @@ def create_customer(customer_data=None):
try:
customer_name = frappe.db.exists("Customer", {"customer_name": customer_data.get("customer_name")})
if not customer_name:
customer = frappe.get_doc({"doctype": "Customer", **customer_data}).insert(
ignore_permissions=True
)
customer = frappe.new_doc("Customer")
for field in CUSTOMER_ALLOWED_FIELDS:
if customer_data.get(field) is not None:
customer.set(field, customer_data.get(field))
customer.insert(ignore_permissions=True)
customer_name = customer.name
contacts = json.loads(customer_data.get("contacts"))

View File

@@ -438,7 +438,7 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
if (frm.doc.routing) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

@@ -126,11 +126,13 @@
"label": "Image"
},
{
"default": "1",
"fetch_from": "operation.batch_size",
"fetch_if_empty": 1,
"fieldname": "batch_size",
"fieldtype": "Int",
"label": "Batch Size"
"fieldtype": "Float",
"label": "Batch Size",
"non_negative": 1
},
{
"depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing",
@@ -196,13 +198,14 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-02-17 15:33:28.495850",
"modified": "2026-05-27 12:09:44.797434",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -17,7 +17,7 @@ class BOMOperation(Document):
base_cost_per_unit: DF.Float
base_hour_rate: DF.Currency
base_operating_cost: DF.Currency
batch_size: DF.Int
batch_size: DF.Float
cost_per_unit: DF.Float
description: DF.TextEditor | None
fixed_time: DF.Check

View File

@@ -21,6 +21,7 @@
"min_order_qty",
"section_break_8",
"sales_order",
"main_item_code",
"bin_qty_section",
"actual_qty",
"requested_qty",
@@ -114,6 +115,14 @@
"options": "Sales Order",
"read_only": 1
},
{
"fieldname": "main_item_code",
"fieldtype": "Data",
"hidden": 1,
"label": "Main Item Code",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "requested_qty",
"fieldtype": "Float",
@@ -213,4 +222,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -20,6 +20,7 @@ class MaterialRequestPlanItem(Document):
from_warehouse: DF.Link | None
item_code: DF.Link
item_name: DF.Data | None
main_item_code: DF.Data | None
material_request_type: DF.Literal[
"", "Purchase", "Material Transfer", "Material Issue", "Manufacture", "Customer Provided"
]

View File

@@ -1397,7 +1397,7 @@ def get_material_request_items(
"sales_order": sales_order,
"description": row.get("description"),
"uom": row.get("purchase_uom") or row.get("stock_uom"),
"main_bom_item": row.get("main_bom_item"),
"main_item_code": row.get("main_bom_item"),
}
@@ -1557,6 +1557,8 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
"item_code": sa_row.production_item,
"required_qty": sa_row.qty,
"include_exploded_items": 0,
"sales_order": sa_row.sales_order,
"main_bom_item": sa_row.parent_item_code,
}
)
)
@@ -1660,6 +1662,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
"stock_uom": item_master.stock_uom,
"conversion_factor": conversion_factor,
"safety_stock": item_master.safety_stock,
"main_bom_item": data.get("main_bom_item"),
}
)

View File

@@ -1254,8 +1254,10 @@ class TestProductionPlan(FrappeTestCase):
plan.get_sub_assembly_items()
mr_items = []
expected_main_item_by_mr_item = {"ChildPart1 For MR": "SubAssembly1-1 For MR"}
for row in plan.sub_assembly_items:
mr_items.append(row.production_item)
expected_main_item_by_mr_item[row.production_item] = row.parent_item_code
row.type_of_manufacturing = "Material Request"
plan.save()
@@ -1265,6 +1267,10 @@ class TestProductionPlan(FrappeTestCase):
for item_code in mr_items:
self.assertTrue(item_code in validate_mr_items)
main_item_by_mr_item = {item.get("item_code"): item.get("main_item_code") for item in items}
for item_code, main_item_code in expected_main_item_by_mr_item.items():
self.assertEqual(main_item_by_mr_item[item_code], main_item_code)
def test_resered_qty_for_production_plan_for_material_requests(self):
from erpnext.stock.utils import get_or_make_bin

View File

@@ -158,7 +158,7 @@ class WorkOrder(Document):
self.calculate_operating_cost()
self.validate_qty()
self.validate_transfer_against()
self.validate_operation_time()
self.validate_operations()
self.status = self.get_status()
self.validate_workstation_type()
self.reset_use_multi_level_bom()
@@ -406,7 +406,7 @@ class WorkOrder(Document):
elif self.docstatus == 1:
if status not in ["Closed", "Stopped"]:
status = "Not Started"
if flt(self.material_transferred_for_manufacturing) > 0:
if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer:
status = "In Process"
precision = frappe.get_precision("Work Order", "produced_qty")
@@ -1120,9 +1120,12 @@ class WorkOrder(Document):
title=_("Missing value"),
)
def validate_operation_time(self):
def validate_operations(self):
for d in self.operations:
if not d.time_in_mins > 0:
if not d.batch_size or d.batch_size <= 0:
d.batch_size = 1
if d.time_in_mins <= 0:
frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation))
def update_required_items(self):

View File

@@ -185,10 +185,11 @@
"read_only": 1
},
{
"default": "1",
"fieldname": "batch_size",
"fieldtype": "Float",
"label": "Batch Size",
"read_only": 1
"non_negative": 1
},
{
"fieldname": "sequence_id",
@@ -225,14 +226,15 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-05-15 15:10:06.885440",
"modified": "2026-05-27 12:56:37.240431",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Operation",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -435,3 +435,4 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
erpnext.patches.v16_0.depends_on_inv_dimensions
erpnext.patches.v16_0.clear_procedures_from_receivable_report
erpnext.patches.v16_0.migrate_address_contact_custom_fields
erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item

View File

@@ -0,0 +1,97 @@
import frappe
def execute():
frappe.reload_doc("manufacturing", "doctype", "material_request_plan_item")
if not frappe.db.has_column("Material Request Plan Item", "main_item_code"):
return
for row in get_material_request_plan_items():
if row.main_item_code:
continue
main_item_code = get_main_item_code(row)
if main_item_code:
frappe.db.set_value(
"Material Request Plan Item",
row.name,
"main_item_code",
main_item_code,
update_modified=False,
)
def get_material_request_plan_items():
return frappe.get_all(
"Material Request Plan Item",
fields=["name", "parent", "item_code", "sales_order", "main_item_code"],
)
def get_main_item_code(row):
return (
get_main_item_code_from_sub_assembly(row)
or get_main_item_code_from_sub_assembly_bom(row)
or get_main_item_code_from_production_plan_bom(row)
)
def get_main_item_code_from_sub_assembly(row):
sub_assembly = frappe.db.get_value(
"Production Plan Sub Assembly Item",
get_filters(
row,
{
"parent": row.parent,
"production_item": row.item_code,
},
),
"parent_item_code",
)
return sub_assembly
def get_main_item_code_from_sub_assembly_bom(row):
for sub_assembly in get_sub_assembly_items(row):
if item_exists_in_bom(row.item_code, sub_assembly.bom_no):
return frappe.db.get_value("BOM", sub_assembly.bom_no, "item")
def get_main_item_code_from_production_plan_bom(row):
for production_plan_item in get_production_plan_items(row):
if item_exists_in_bom(row.item_code, production_plan_item.bom_no):
return frappe.db.get_value("BOM", production_plan_item.bom_no, "item")
def get_sub_assembly_items(row):
return frappe.get_all(
"Production Plan Sub Assembly Item",
filters=get_filters(row, {"parent": row.parent}),
fields=["bom_no"],
)
def get_production_plan_items(row):
return frappe.get_all(
"Production Plan Item",
filters=get_filters(row, {"parent": row.parent}),
fields=["bom_no"],
)
def get_filters(row, filters):
if row.sales_order:
filters["sales_order"] = row.sales_order
return filters
def item_exists_in_bom(item_code, bom_no):
if not bom_no:
return False
return frappe.db.exists("BOM Item", {"parent": bom_no, "item_code": item_code}) or frappe.db.exists(
"BOM Explosion Item", {"parent": bom_no, "item_code": item_code}
)

View File

@@ -1,6 +1,5 @@
{
"actions": [],
"beta": 1,
"creation": "2016-04-22 05:27:52.109319",
"doctype": "DocType",
"document_type": "Setup",
@@ -87,7 +86,7 @@
],
"issingle": 1,
"links": [],
"modified": "2022-12-19 21:10:29.127277",
"modified": "2026-05-30 20:51:04.415019",
"modified_by": "Administrator",
"module": "Portal",
"name": "Homepage",
@@ -114,6 +113,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],

View File

@@ -176,14 +176,9 @@ erpnext.buying = {
callback: (r) => {
if (!r.message) return;
if (!this.frm.doc.billing_address) {
this.frm.set_value("billing_address", r.message.primary_address || "");
}
this.frm.set_value("billing_address", r.message.primary_address || "");
if (
frappe.meta.has_field(this.frm.doc.doctype, "shipping_address") &&
!this.frm.doc.shipping_address
) {
if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) {
this.frm.set_value("shipping_address", r.message.shipping_address || "");
}
},

View File

@@ -418,7 +418,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
})
}
onload_post_render() {
if(this.frm.doc.__islocal && !(this.frm.doc.taxes || []).length
&& !this.frm.doc.__onload?.load_after_mapping) {
@@ -659,15 +658,23 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
},
async () => {
// for internal customer instead of pricing rule directly apply valuation rate on item
const fetch_valuation_rate_for_internal_transactions = await frappe.db.get_single_value(
"Accounts Settings", "fetch_valuation_rate_for_internal_transaction"
);
if ((me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier) && fetch_valuation_rate_for_internal_transactions) {
me.get_incoming_rate(item, me.frm.posting_date, me.frm.posting_time,
me.frm.doc.doctype, me.frm.doc.company);
} else {
me.frm.script_manager.trigger("price_list_rate", cdt, cdn);
if ((me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier)) {
const fetch_valuation_rate_for_internal_transactions = await frappe.db.get_single_value(
"Accounts Settings", "fetch_valuation_rate_for_internal_transaction"
);
if (fetch_valuation_rate_for_internal_transactions) {
me.get_incoming_rate(
item,
me.frm.posting_date,
me.frm.posting_time,
me.frm.doc.doctype,
me.frm.doc.company
);
return;
}
}
me.frm.script_manager.trigger("price_list_rate", cdt, cdn);
},
() => {
if (me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier) {
@@ -2506,11 +2513,29 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
method: "erpnext.controllers.stock_controller.check_item_quality_inspection",
args: {
doctype: this.frm.doc.doctype,
items: this.frm.doc.items
docstatus: this.frm.doc.docstatus,
items: this.frm.doc.items,
},
freeze: true,
callback: function (r) {
r.message.forEach(item => {
if (r.message.length == 0) {
let type = inspection_type === "Incoming" ? "Purchase" : "Delivery";
let fieldname =
inspection_type === "Incoming"
? "Inspection Required before Purchase"
: "Inspection Required before Delivery";
frappe.msgprint({
title: __("Quality Inspection Not Configured"),
message: __(`Enable <b>{0}</b> on the Item master to proceed with {1} inspection.`, [
fieldname,
type,
]),
});
return;
}
r.message.forEach((item) => {
if (me.has_inspection_required(item)) {
let dialog_items = dialog.fields_dict.items;
dialog_items.df.data.push({

View File

@@ -651,6 +651,7 @@ erpnext.utils.update_child_items = function (opts) {
read_only: 0,
disabled: 0,
label: __("Item Code"),
formatter: (value) => value,
get_query: function () {
let filters;
if (frm.doc.doctype == "Sales Order") {

View File

@@ -305,6 +305,7 @@
"fieldname": "customer_primary_contact",
"fieldtype": "Link",
"label": "Customer Primary Contact",
"no_copy": 1,
"options": "Contact"
},
{
@@ -312,6 +313,7 @@
"fieldname": "mobile_no",
"fieldtype": "Read Only",
"label": "Mobile No",
"no_copy": 1,
"options": "Mobile"
},
{
@@ -319,6 +321,7 @@
"fieldname": "email_id",
"fieldtype": "Read Only",
"label": "Email Id",
"no_copy": 1,
"options": "Email"
},
{
@@ -330,12 +333,14 @@
"fieldname": "customer_primary_address",
"fieldtype": "Link",
"label": "Customer Primary Address",
"no_copy": 1,
"options": "Address"
},
{
"fieldname": "primary_address",
"fieldtype": "Text",
"label": "Primary Address",
"no_copy": 1,
"read_only": 1
},
{
@@ -590,14 +595,16 @@
"fieldname": "first_name",
"fieldtype": "Read Only",
"hidden": 1,
"label": "First Name"
"label": "First Name",
"no_copy": 1
},
{
"fetch_from": "customer_primary_contact.last_name",
"fieldname": "last_name",
"fieldtype": "Read Only",
"hidden": 1,
"label": "Last Name"
"label": "Last Name",
"no_copy": 1
}
],
"icon": "fa fa-user",
@@ -611,7 +618,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-01-21 17:23:42.151114",
"modified": "2026-05-29 16:52:59.441272",
"modified_by": "Administrator",
"module": "Selling",
"name": "Customer",

View File

@@ -308,7 +308,6 @@
"read_only": 1
},
{
"depends_on": "eval:(doc.quotation_to=='Customer' && doc.party_name)",
"fieldname": "col_break98",
"fieldtype": "Column Break",
"width": "50%"
@@ -1108,7 +1107,7 @@
"idx": 82,
"is_submittable": 1,
"links": [],
"modified": "2025-07-31 17:23:48.875382",
"modified": "2026-05-30 17:40:02.667637",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",

View File

@@ -26,7 +26,7 @@ frappe.ui.form.on("Sales Order", {
let color;
if (!doc.qty && frm.doc.has_unit_price_items) {
color = "yellow";
} else if (doc.stock_qty <= doc.actual_qty) {
} else if (doc.stock_qty - doc.delivered_qty <= doc.actual_qty) {
color = "green";
} else {
color = "orange";

View File

@@ -10,6 +10,7 @@ import frappe.utils
from frappe import _, qb
from frappe.contacts.doctype.address.address import get_company_address
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import Sum
@@ -20,7 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
update_linked_doc,
validate_inter_company_party,
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
from erpnext.controllers.selling_controller import SellingController
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order,
@@ -1332,7 +1333,9 @@ def get_events(start, end, filters=None):
@frappe.whitelist()
def make_purchase_order_for_default_supplier(source_name, selected_items=None, target_doc=None):
def make_purchase_order_for_default_supplier(
source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None
):
"""Creates Purchase Order for each Supplier. Returns a list of doc objects."""
from erpnext.setup.utils import get_exchange_rate
@@ -1361,7 +1364,6 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
target.shipping_rule = ""
target.tc_name = ""
target.terms = ""
target.payment_terms_template = ""
target.payment_schedule = []
default_price_list = frappe.get_value("Supplier", supplier, "default_price_list")
@@ -1418,16 +1420,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
{
"Sales Order": {
"doctype": "Purchase Order",
"field_no_map": [
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"taxes_and_charges",
"shipping_address",
"dispatch_address",
],
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
"validation": {"docstatus": ["=", 1]},
},
"Sales Order Item": {
@@ -1492,7 +1485,9 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
@frappe.whitelist()
def make_purchase_order(source_name, selected_items=None, target_doc=None):
def make_purchase_order(
source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None
):
if not selected_items:
return
@@ -1520,7 +1515,6 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
target.shipping_rule = ""
target.tc_name = ""
target.terms = ""
target.payment_terms_template = ""
target.payment_schedule = []
if is_drop_ship_order(target):
@@ -1559,16 +1553,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
{
"Sales Order": {
"doctype": "Purchase Order",
"field_no_map": [
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"taxes_and_charges",
"shipping_address",
"dispatch_address",
],
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
"validation": {"docstatus": ["=", 1]},
},
"Sales Order Item": {

View File

@@ -24,6 +24,8 @@ from erpnext.selling.doctype.sales_order.sales_order import (
create_pick_list,
make_delivery_note,
make_material_request,
make_purchase_order,
make_purchase_order_for_default_supplier,
make_raw_material_request,
make_sales_invoice,
make_work_orders,
@@ -692,6 +694,40 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
workflow.is_active = 0
workflow.save()
def test_update_child_qty_rate_follows_allow_edit(self):
from frappe.model.workflow import apply_workflow
workflow = make_sales_order_edit_perm_workflow()
so = make_sales_order(item_code="_Test Item", qty=1, rate=150, do_not_submit=1)
apply_workflow(so, "Approve")
trans_item = json.dumps(
[{"item_code": "_Test Item", "rate": 150, "qty": 2, "docname": so.items[0].name}]
)
mover = "test@example.com"
mover_user = frappe.get_doc("User", mover)
mover_user.add_roles("Sales User", "Test Junior Approver")
with self.set_user(mover):
# transitioned the doc into Approved but is not the configured editor
self.assertRaises(
frappe.ValidationError, update_child_qty_rate, "Sales Order", trans_item, so.name
)
editor = "test2@example.com"
editor_user = frappe.get_doc("User", editor)
editor_user.add_roles("Sales User", "Test Approver")
with self.set_user(editor):
# Test Approver is the "Only Allow Edit For" role on Approved
update_child_qty_rate("Sales Order", trans_item, so.name)
so.reload()
self.assertEqual(so.items[0].qty, 2)
mover_user.remove_roles("Sales User", "Test Junior Approver", "Test Approver")
editor_user.remove_roles("Sales User", "Test Junior Approver", "Test Approver")
workflow.is_active = 0
workflow.save()
def test_material_request_for_product_bundle(self):
# Create the Material Request from the sales order for the Packing Items
# Check whether the material request has the correct packing item or not.
@@ -1330,8 +1366,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
Tests if the the Product Bundles in the Items table of Sales Orders are replaced with
their child items(from the Packed Items table) on creating a Purchase Order from it.
"""
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0})
make_item("_Test Bundle Item 1", {"is_stock_item": 1})
make_item("_Test Bundle Item 2", {"is_stock_item": 1})
@@ -1360,8 +1394,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
"""
Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order
"""
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0})
make_item("_Test Bundle Item 1", {"is_stock_item": 1})
make_item("_Test Bundle Item 2", {"is_stock_item": 1})
@@ -2385,8 +2417,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertRaises(frappe.ValidationError, so1.update_status, "Draft")
def test_item_tax_transfer_from_sales_to_purchase(self):
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
item_tax = frappe.new_doc("Item Tax Template")
item_tax.title = "Test Item Tax Template"
item_tax.company = "_Test Company"
@@ -2487,6 +2517,33 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertFalse(so.per_billed)
self.assertEqual(so.status, "To Deliver and Bill")
def test_make_purchase_order_does_not_inherit_party_fields(self):
"""
Customer-derived fields must not leak from a drop-ship SO into the PO.
"""
so_items = [
{
"item_code": "_Test Item",
"warehouse": "",
"qty": 1,
"rate": 100,
"delivered_by_supplier": 1,
"supplier": "_Test Supplier",
}
]
so = make_sales_order(item_list=so_items, do_not_submit=True)
so.tax_category = "_Test Tax Category 1"
so.language = "ar"
so.payment_terms_template = "_Test Payment Term Template"
so.submit()
po = make_purchase_order_for_default_supplier(so.name, selected_items=so_items)[0]
supplier = frappe.get_doc("Supplier", "_Test Supplier")
self.assertEqual(po.tax_category or None, supplier.tax_category or None)
self.assertEqual(po.language or None, supplier.language or None)
self.assertEqual(po.payment_terms_template or None, supplier.payment_terms or None)
def test_pending_quantity_after_update_item_during_invoice_creation(self):
so = make_sales_order(qty=30, rate=100)
@@ -2683,3 +2740,41 @@ def make_sales_order_workflow():
workflow.insert(ignore_permissions=True)
return workflow
def make_sales_order_edit_perm_workflow():
if frappe.db.exists("Workflow", "SO Edit Perm Workflow"):
doc = frappe.get_doc("Workflow", "SO Edit Perm Workflow")
doc.set("is_active", 1)
doc.save()
return doc
frappe.get_doc(doctype="Role", role_name="Test Junior Approver").insert(ignore_if_duplicate=True)
frappe.get_doc(doctype="Role", role_name="Test Approver").insert(ignore_if_duplicate=True)
frappe.cache().hdel("roles", frappe.session.user)
workflow = frappe.get_doc(
{
"doctype": "Workflow",
"workflow_name": "SO Edit Perm Workflow",
"document_type": "Sales Order",
"workflow_state_field": "workflow_state",
"is_active": 1,
"send_email_alert": 0,
}
)
workflow.append("states", dict(state="Pending", allow_edit="All"))
workflow.append("states", dict(state="Approved", allow_edit="Test Approver", doc_status=1))
workflow.append(
"transitions",
dict(
state="Pending",
action="Approve",
next_state="Approved",
allowed="Test Junior Approver",
allow_self_approval=1,
),
)
workflow.insert(ignore_permissions=True)
return workflow

View File

@@ -377,42 +377,80 @@ def get_past_order_list(search_term, status, limit=20):
@frappe.whitelist()
def set_customer_info(fieldname, customer, value=""):
customer_doc = frappe.get_doc("Customer", customer)
customer_doc.check_permission("write")
if fieldname == "loyalty_program":
frappe.db.set_value("Customer", customer, "loyalty_program", value)
customer_doc.loyalty_program = value
else:
contact = customer_doc.get("customer_primary_contact")
if not contact:
Contact = DocType("Contact")
DynamicLink = DocType("Dynamic Link")
contact = frappe.get_cached_value("Customer", customer, "customer_primary_contact")
if not contact:
contact = frappe.db.sql(
"""
SELECT parent FROM `tabDynamic Link`
WHERE
parenttype = 'Contact' AND
parentfield = 'links' AND
link_doctype = 'Customer' AND
link_name = %s
""",
(customer),
as_dict=1,
)
contact = contact[0].get("parent") if contact else None
# Inner join with Contact DocType, to priorities records that have is_primary_contact set.
query = (
frappe.qb.from_(DynamicLink)
.join(Contact)
.on(DynamicLink.parent == Contact.name)
.select(DynamicLink.parent)
.where(
(DynamicLink.link_name == customer)
& (DynamicLink.parentfield == "links")
& (DynamicLink.parenttype == "Contact")
& (DynamicLink.link_doctype == "Customer")
)
.orderby(Contact.is_primary_contact, order=Order.desc)
)
if not contact:
new_contact = frappe.new_doc("Contact")
new_contact.is_primary_contact = 1
new_contact.first_name = customer
new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}])
new_contact.save()
contact = new_contact.name
frappe.db.set_value("Customer", customer, "customer_primary_contact", contact)
contacts = query.run(pluck=DynamicLink.parent)
contact_doc = frappe.get_doc("Contact", contact)
if fieldname == "email_id":
contact_doc.set("email_ids", [{"email_id": value, "is_primary": 1}])
frappe.db.set_value("Customer", customer, "email_id", value)
elif fieldname == "mobile_no":
contact_doc.set("phone_nos", [{"phone": value, "is_primary_mobile_no": 1}])
frappe.db.set_value("Customer", customer, "mobile_no", value)
contact_doc.save()
contact = contacts[0] if contacts else None
if not contact:
new_contact = frappe.new_doc("Contact")
new_contact.is_primary_contact = 1
new_contact.first_name = customer
new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}])
new_contact.save()
contact = new_contact.name
def set_primary_phone_no_email(field, value):
# Create new record instead deleting existing email or phone_no and setting the new row as primary.
field_mapper = {
"email_ids": {"field": "email_id", "primary": "is_primary"},
"phone_nos": {"field": "phone", "primary": "is_primary_mobile_no"},
}
value_already_exists = False
for d in contact_doc.get(field):
if d.get(field_mapper[field].get("field")) == value and not value_already_exists:
d.set(field_mapper[field]["primary"], 1)
value_already_exists = True
continue
d.set(field_mapper[field]["primary"], 0)
if not value_already_exists:
contact_doc.append(
field, {field_mapper[field]["field"]: value, field_mapper[field]["primary"]: 1}
)
contact_doc = frappe.get_doc("Contact", contact)
# setting is_primary_contact = 1 on Contact to refetch the same contact incase it's removed from Customer records.
contact_doc.set("is_primary_contact", 1)
if fieldname == "email_id":
set_primary_phone_no_email("email_ids", value)
elif fieldname == "mobile_no":
set_primary_phone_no_email("phone_nos", value)
# Saving contact_doc to set mobile_no and email.
contact_doc.save()
# Auto-fetches from Contact DocType, no need to set values separately.
customer_doc.customer_primary_contact = contact
# using save method instead db.set_value which bypasses the validation for loyalty program
# and auto sets the mobile_no and email field on customer records.
customer_doc.save()
@frappe.whitelist()

View File

@@ -174,8 +174,8 @@ erpnext.PointOfSale.Controller = class {
set_opening_entry_status() {
this.page.set_title_sub(
`<span class="indicator orange">
<a class="text-muted" href="#Form/POS%20Opening%20Entry/${this.pos_opening}">
Opened at ${frappe.datetime.str_to_user(this.pos_opening_time)}
<a class="text-muted" href="#Form/POS%20Opening%20Entry/${encodeURIComponent(this.pos_opening)}">
Opened at ${frappe.utils.escape_html(frappe.datetime.str_to_user(this.pos_opening_time))}
</a>
</span>`
);

View File

@@ -178,7 +178,7 @@ erpnext.PointOfSale.ItemCart = class {
me.$totals_section.find(".edit-cart-btn").click();
}
const item_row_name = unescape($cart_item.attr("data-row-name"));
const item_row_name = $cart_item.attr("data-row-name");
me.events.cart_item_clicked({ name: item_row_name });
this.numpad_value = "";
});
@@ -453,10 +453,10 @@ erpnext.PointOfSale.ItemCart = class {
<div class="customer-display">
${this.get_customer_image()}
<div class="customer-name-desc">
<div class="customer-name">${customer_name}</div>
<div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
${get_customer_description()}
</div>
<div class="reset-customer-btn" data-customer="${escape(customer)}">
<div class="reset-customer-btn" data-customer="${frappe.utils.escape_html(customer)}">
<svg width="32" height="32" viewBox="0 0 14 14" fill="none">
<path d="M4.93764 4.93759L7.00003 6.99998M9.06243 9.06238L7.00003 6.99998M7.00003 6.99998L4.93764 9.06238L9.06243 4.93759" stroke="#8D99A6"/>
</svg>
@@ -473,11 +473,13 @@ erpnext.PointOfSale.ItemCart = class {
if (!email_id && !mobile_no) {
return `<div class="customer-desc">${__("Click to add email / phone")}</div>`;
} else if (email_id && !mobile_no) {
return `<div class="customer-desc">${email_id}</div>`;
return `<div class="customer-desc">${frappe.utils.escape_html(email_id)}</div>`;
} else if (mobile_no && !email_id) {
return `<div class="customer-desc">${mobile_no}</div>`;
return `<div class="customer-desc">${frappe.utils.escape_html(mobile_no)}</div>`;
} else {
return `<div class="customer-desc">${email_id} - ${mobile_no}</div>`;
return `<div class="customer-desc">${frappe.utils.escape_html(
email_id
)} - ${frappe.utils.escape_html(mobile_no)}</div>`;
}
}
}
@@ -485,9 +487,13 @@ erpnext.PointOfSale.ItemCart = class {
get_customer_image() {
const { customer, image } = this.customer_info || {};
if (image) {
return `<div class="customer-image"><img src="${image}" alt="${image}""></div>`;
return `<div class="customer-image"><img src="${frappe.utils.escape_html(
image
)}" alt="${frappe.utils.escape_html(image)}"></div>`;
} else {
return `<div class="customer-image customer-abbr">${frappe.get_abbr(customer)}</div>`;
return `<div class="customer-image customer-abbr">${frappe.utils.escape_html(
frappe.get_abbr(customer)
)}</div>`;
}
}
@@ -549,10 +555,10 @@ erpnext.PointOfSale.ItemCart = class {
if (t.tax_amount_after_discount_amount == 0.0) return;
// if tax rate is 0, don't print it.
const description = /[0-9]+/.test(t.description)
? t.description
? frappe.utils.escape_html(t.description)
: t.rate != 0
? `${t.description} @ ${t.rate}%`
: t.description;
? `${frappe.utils.escape_html(t.description)} @ ${t.rate}%`
: frappe.utils.escape_html(t.description);
return `<div class="tax-row">
<div class="tax-label">${description}</div>
<div class="tax-value">${format_currency(t.tax_amount_after_discount_amount, currency)}</div>
@@ -566,8 +572,9 @@ erpnext.PointOfSale.ItemCart = class {
}
get_cart_item({ name }) {
const item_selector = `.cart-item-wrapper[data-row-name="${escape(name)}"]`;
return this.$cart_items_wrapper.find(item_selector);
return this.$cart_items_wrapper.find(".cart-item-wrapper").filter(function () {
return $(this).attr("data-row-name") === name;
});
}
get_item_from_frm(item) {
@@ -597,7 +604,9 @@ erpnext.PointOfSale.ItemCart = class {
if (!$item_to_update.length) {
this.$cart_items_wrapper.append(
`<div class="cart-item-wrapper" data-row-name="${escape(item_data.name)}"></div>
`<div class="cart-item-wrapper" data-row-name="${frappe.utils.escape_html(
item_data.name
)}"></div>
<div class="seperator"></div>`
);
$item_to_update = this.get_cart_item(item_data);
@@ -607,7 +616,7 @@ erpnext.PointOfSale.ItemCart = class {
`${get_item_image_html()}
<div class="item-name-desc">
<div class="item-name">
${item_data.item_name}
${frappe.utils.escape_html(item_data.item_name)}
</div>
${get_description_html()}
</div>
@@ -636,7 +645,7 @@ erpnext.PointOfSale.ItemCart = class {
if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) {
return `
<div class="item-qty-rate">
<div class="item-qty"><span>${item_data.qty || 0} ${item_data.uom}</span></div>
<div class="item-qty"><span>${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</span></div>
<div class="item-rate-amount">
<div class="item-rate">${format_currency(item_data.amount, currency)}</div>
<div class="item-amount">${format_currency(item_data.rate, currency)}</div>
@@ -645,7 +654,7 @@ erpnext.PointOfSale.ItemCart = class {
} else {
return `
<div class="item-qty-rate">
<div class="item-qty"><span>${item_data.qty || 0} ${item_data.uom}</span></div>
<div class="item-qty"><span>${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</span></div>
<div class="item-rate-amount">
<div class="item-rate">${format_currency(item_data.rate, currency)}</div>
</div>
@@ -666,7 +675,7 @@ erpnext.PointOfSale.ItemCart = class {
}
}
item_data.description = frappe.ellipsis(item_data.description, 45);
return `<div class="item-desc">${item_data.description}</div>`;
return `<div class="item-desc">${frappe.utils.escape_html(item_data.description)}</div>`;
}
return ``;
}
@@ -678,22 +687,26 @@ erpnext.PointOfSale.ItemCart = class {
<div class="item-image">
<img
onerror="cur_pos.cart.handle_broken_image(this)"
src="${image}" alt="${frappe.get_abbr(item_name)}"">
src="${frappe.utils.escape_html(image)}" alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}">
</div>`;
} else {
return `<div class="item-image item-abbr">${frappe.get_abbr(item_name)}</div>`;
return `<div class="item-image item-abbr">${frappe.utils.escape_html(
frappe.get_abbr(item_name)
)}</div>`;
}
}
}
handle_broken_image($img) {
const item_abbr = $($img).attr("alt");
$($img).parent().replaceWith(`<div class="item-image item-abbr">${item_abbr}</div>`);
$($img)
.parent()
.replaceWith(`<div class="item-image item-abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
}
update_selector_value_in_cart_item(selector, value, item) {
const $item_to_update = this.get_cart_item(item);
$item_to_update.attr(`data-${selector}`, escape(value));
$item_to_update.attr(`data-${selector}`, value);
}
toggle_checkout_btn(show_checkout) {
@@ -892,8 +905,8 @@ erpnext.PointOfSale.ItemCart = class {
<div class="customer-display">
${this.get_customer_image()}
<div class="customer-name-desc">
<div class="customer-name">${customer_name}</div>
<div class="customer-desc">${customer}</div>
<div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
<div class="customer-desc">${frappe.utils.escape_html(customer)}</div>
</div>
</div>
<div class="customer-fields-container">
@@ -980,6 +993,7 @@ erpnext.PointOfSale.ItemCart = class {
customer: current_customer,
value: this.value,
},
freeze: true,
callback: (r) => {
if (!r.exc) {
me.customer_info[this.df.fieldname] = this.value;
@@ -1029,9 +1043,11 @@ erpnext.PointOfSale.ItemCart = class {
};
transaction_container.append(
`<div class="invoice-wrapper" data-invoice-name="${escape(invoice.name)}">
`<div class="invoice-wrapper" data-invoice-name="${frappe.utils.escape_html(
invoice.name
)}">
<div class="invoice-name-date">
<div class="invoice-name">${invoice.name}</div>
<div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
<div class="invoice-date">${posting_datetime}</div>
</div>
<div class="invoice-total-status">
@@ -1039,7 +1055,7 @@ erpnext.PointOfSale.ItemCart = class {
${format_currency(invoice.grand_total, invoice.currency, frappe.sys_defaults.currency_precision) || 0}
</div>
<div class="invoice-status">
<span class="indicator-pill whitespace-nowrap ${indicator_color[invoice.status]}">
<span class="indicator-pill whitespace-nowrap ${indicator_color[invoice.status] || ""}">
<span>${__(invoice.status)}</span>
</span>
</div>

View File

@@ -128,25 +128,27 @@ erpnext.PointOfSale.ItemDetails = class {
return ``;
}
this.$item_name.html(item_name);
this.$item_name.html(frappe.utils.escape_html(item_name));
this.$item_description.html(get_description_html());
this.$item_price.html(format_currency(price_list_rate, this.currency));
if (!this.hide_images && image) {
this.$item_image.html(
`<img
onerror="cur_pos.item_details.handle_broken_image(this)"
class="h-full" src="${image}"
alt="${frappe.get_abbr(item_name)}"
class="h-full" src="${frappe.utils.escape_html(image)}"
alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}"
style="object-fit: cover;">`
);
} else {
this.$item_image.html(`<div class="item-abbr">${frappe.get_abbr(item_name)}</div>`);
this.$item_image.html(
`<div class="item-abbr">${frappe.utils.escape_html(frappe.get_abbr(item_name))}</div>`
);
}
}
handle_broken_image($img) {
const item_abbr = $($img).attr("alt");
$($img).replaceWith(`<div class="item-abbr">${item_abbr}</div>`);
$($img).replaceWith(`<div class="item-abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
}
render_discount_dom(item) {

View File

@@ -107,39 +107,45 @@ erpnext.PointOfSale.ItemSelector = class {
<div class="flex items-center justify-center border-b-grey text-6xl text-grey-100" style="height:8rem; min-height:8rem">
<img
onerror="cur_pos.item_selector.handle_broken_image(this)"
class="h-full item-img" src="${item_image}"
alt="${frappe.get_abbr(item.item_name)}"
class="h-full item-img" src="${frappe.utils.escape_html(item_image)}"
alt="${frappe.utils.escape_html(frappe.get_abbr(item.item_name))}"
>
</div>`;
} else {
return `<div class="item-qty-pill">
<span class="indicator-pill whitespace-nowrap ${indicator_color}">${qty_to_display}</span>
</div>
<div class="item-display abbr">${frappe.get_abbr(item.item_name)}</div>`;
<div class="item-display abbr">${frappe.utils.escape_html(frappe.get_abbr(item.item_name))}</div>`;
}
}
return `<div class="item-wrapper"
data-item-code="${escape(item.item_code)}" data-serial-no="${escape(serial_no)}"
data-batch-no="${escape(batch_no)}" data-uom="${escape(uom)}"
data-rate="${escape(price_list_rate || 0)}"
data-stock-uom="${escape(item.stock_uom)}"
title="${item.item_name}">
data-item-code="${frappe.utils.escape_html(item.item_code)}" data-serial-no="${frappe.utils.escape_html(
serial_no
)}"
data-batch-no="${frappe.utils.escape_html(batch_no)}" data-uom="${frappe.utils.escape_html(uom)}"
data-rate="${frappe.utils.escape_html(price_list_rate || 0)}"
data-stock-uom="${frappe.utils.escape_html(item.stock_uom)}"
title="${frappe.utils.escape_html(item.item_name)}">
${get_item_image_html()}
<div class="item-detail">
<div class="item-name">
${frappe.ellipsis(item.item_name, 18)}
${frappe.utils.escape_html(frappe.ellipsis(item.item_name, 18))}
</div>
<div class="item-rate">${format_currency(price_list_rate, item.currency, precision) || 0} / ${uom}</div>
<div class="item-rate">${
format_currency(price_list_rate, item.currency, precision) || 0
} / ${frappe.utils.escape_html(uom)}</div>
</div>
</div>`;
}
handle_broken_image($img) {
const item_abbr = $($img).attr("alt");
$($img).parent().replaceWith(`<div class="item-display abbr">${item_abbr}</div>`);
$($img)
.parent()
.replaceWith(`<div class="item-display abbr">${frappe.utils.escape_html(item_abbr)}</div>`);
}
make_search_bar() {
@@ -252,14 +258,13 @@ erpnext.PointOfSale.ItemSelector = class {
this.$component.on("click", ".item-wrapper", function () {
const $item = $(this);
const item_code = unescape($item.attr("data-item-code"));
let batch_no = unescape($item.attr("data-batch-no"));
let serial_no = unescape($item.attr("data-serial-no"));
let uom = unescape($item.attr("data-uom"));
let rate = unescape($item.attr("data-rate"));
let stock_uom = unescape($item.attr("data-stock-uom"));
const item_code = $item.attr("data-item-code");
let batch_no = $item.attr("data-batch-no");
let serial_no = $item.attr("data-serial-no");
let uom = $item.attr("data-uom");
let rate = $item.attr("data-rate");
let stock_uom = $item.attr("data-stock-uom");
// escape(undefined) returns "undefined" then unescape returns "undefined"
batch_no = batch_no === "undefined" ? undefined : batch_no;
serial_no = serial_no === "undefined" ? undefined : serial_no;
uom = uom === "undefined" ? undefined : uom;

View File

@@ -38,7 +38,7 @@ erpnext.PointOfSale.PastOrderList = class {
});
const me = this;
this.$invoices_container.on("click", ".invoice-wrapper", function () {
const invoice_name = unescape($(this).attr("data-invoice-name"));
const invoice_name = $(this).attr("data-invoice-name");
me.events.open_invoice_data(invoice_name);
});
@@ -99,14 +99,14 @@ erpnext.PointOfSale.PastOrderList = class {
const posting_datetime = frappe.datetime.str_to_user(
invoice.posting_date + " " + invoice.posting_time
);
return `<div class="invoice-wrapper" data-invoice-name="${escape(invoice.name)}">
return `<div class="invoice-wrapper" data-invoice-name="${frappe.utils.escape_html(invoice.name)}">
<div class="invoice-name-date">
<div class="invoice-name">${invoice.name}</div>
<div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
<div class="invoice-date">
<svg class="mr-2" width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
${frappe.ellipsis(invoice.customer_name, 20)}
${frappe.utils.escape_html(frappe.ellipsis(invoice.customer_name, 20))}
</div>
</div>
<div class="invoice-total-status">

View File

@@ -81,23 +81,27 @@ erpnext.PointOfSale.PastOrderSummary = class {
return `<div class="left-section">
<div class="customer-section">
<div class="customer-name">${doc.customer_name}</div>
${is_customer_naming_by_customer_name ? `<div class="customer-code">${doc.customer}</div>` : ""}
<div class="customer-email">${this.customer_email}</div>
<div class="customer-name">${frappe.utils.escape_html(doc.customer_name)}</div>
${
is_customer_naming_by_customer_name
? `<div class="customer-code">${frappe.utils.escape_html(doc.customer)}</div>`
: ""
}
<div class="customer-email">${frappe.utils.escape_html(this.customer_email)}</div>
</div>
<div class="cashier">${__("Sold by")}: ${doc.owner}</div>
<div class="cashier">${__("Sold by")}: ${frappe.utils.escape_html(doc.owner)}</div>
</div>
<div class="right-section">
<div class="paid-amount">${format_currency(doc.paid_amount, doc.currency)}</div>
<div class="invoice-name">${doc.name}</div>
<div class="invoice-name">${frappe.utils.escape_html(doc.name)}</div>
<span class="indicator-pill whitespace-nowrap ${indicator_color}"><span>${__(doc.status)}</span></span>
</div>`;
}
get_item_html(doc, item_data) {
return `<div class="item-row-wrapper">
<div class="item-name">${item_data.item_name}</div>
<div class="item-qty">${item_data.qty || 0} ${item_data.uom}</div>
<div class="item-name">${frappe.utils.escape_html(item_data.item_name)}</div>
<div class="item-qty">${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</div>
<div class="item-rate-disc">${get_rate_discount_html()}</div>
</div>`;
@@ -139,10 +143,10 @@ erpnext.PointOfSale.PastOrderSummary = class {
.map((t) => {
// if tax rate is 0, don't print it.
const description = /[0-9]+/.test(t.description)
? t.description
? frappe.utils.escape_html(t.description)
: t.rate != 0
? `${t.description} @ ${t.rate}%`
: t.description;
? `${frappe.utils.escape_html(t.description)} @ ${t.rate}%`
: frappe.utils.escape_html(t.description);
return `
<div class="tax-row">
<div class="tax-label">${description}</div>

View File

@@ -408,8 +408,10 @@ erpnext.PointOfSale.Payment = class {
return `
<div class="payment-mode-wrapper">
<div class="mode-of-payment" data-mode="${mode}" data-payment-type="${payment_type}">
${p.mode_of_payment}
<div class="mode-of-payment" data-mode="${mode}" data-payment-type="${frappe.utils.escape_html(
payment_type
)}">
${frappe.utils.escape_html(p.mode_of_payment)}
<div class="${mode}-amount pay-amount">${amount}</div>
<div class="${mode} mode-of-payment-control"></div>
</div>
@@ -544,7 +546,7 @@ erpnext.PointOfSale.Payment = class {
<div class="mode-of-payment loyalty-card" data-mode="loyalty-amount" data-payment-type="loyalty-amount">
Redeem Loyalty Points
<div class="loyalty-amount-amount pay-amount">${amount}</div>
<div class="loyalty-amount-name">${loyalty_program}</div>
<div class="loyalty-amount-name">${frappe.utils.escape_html(loyalty_program)}</div>
<div class="loyalty-amount mode-of-payment-control"></div>
</div>
</div>`

View File

@@ -11,9 +11,9 @@ field_map = {
"name",
"address_line1",
"address_line2",
"pincode",
"city",
"state",
"pincode",
"country",
"is_primary_address",
],

View File

@@ -138,12 +138,30 @@ class Analytics:
self.get_sales_transactions_based_on_project()
self.get_rows()
def _get_permitted_parent_names(self):
return frappe.get_list(
self.filters.doc_type,
fields=["name"],
filters={
"docstatus": 1,
"company": ["in", self.filters.company],
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
},
pluck="name",
)
def get_sales_transactions_based_on_order_type(self):
if self.filters["value_quantity"] == "Value":
value_field = "base_net_total"
else:
value_field = "total_qty"
permitted_names = self._get_permitted_parent_names()
if not permitted_names:
self.entries = []
self.get_teams()
return
doctype = DocType(self.filters.doc_type)
self.entries = (
@@ -153,12 +171,7 @@ class Analytics:
doctype[self.date_field],
doctype[value_field].as_("value_field"),
)
.where(
(doctype.docstatus == 1)
& (doctype.company.isin(self.filters.company))
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
& (IfNull(doctype.order_type, "") != "")
)
.where((doctype.name.isin(permitted_names)) & (IfNull(doctype.order_type, "") != ""))
.orderby(doctype.order_type)
).run(as_dict=True)
@@ -186,8 +199,10 @@ class Analytics:
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
filters.update({"is_opening": "No"})
self.entries = frappe.get_all(
self.filters.doc_type, fields=[entity, entity_name, value_field, self.date_field], filters=filters
self.entries = frappe.get_list(
self.filters.doc_type,
fields=[entity, entity_name, value_field, self.date_field],
filters=filters,
)
self.entity_names = {}
@@ -200,6 +215,12 @@ class Analytics:
else:
value_field = "stock_qty"
permitted_names = self._get_permitted_parent_names()
if not permitted_names:
self.entries = []
self.entity_names = {}
return
doctype = DocType(self.filters.doc_type)
doctype_item = DocType(f"{self.filters.doc_type} Item")
@@ -214,11 +235,7 @@ class Analytics:
doctype_item[value_field].as_("value_field"),
doctype[self.date_field],
)
.where(
(doctype_item.docstatus == 1)
& (doctype.company.isin(self.filters.company))
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
)
.where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names)))
).run(as_dict=True)
self.entity_names = {}
@@ -248,7 +265,7 @@ class Analytics:
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
filters.update({"is_opening": "No"})
self.entries = frappe.get_all(
self.entries = frappe.get_list(
self.filters.doc_type,
fields=[entity_field, value_field, self.date_field],
filters=filters,
@@ -261,6 +278,12 @@ class Analytics:
else:
value_field = "qty"
permitted_names = self._get_permitted_parent_names()
if not permitted_names:
self.entries = []
self.get_groups()
return
doctype = DocType(self.filters.doc_type)
doctype_item = DocType(f"{self.filters.doc_type} Item")
@@ -273,11 +296,7 @@ class Analytics:
doctype_item[value_field].as_("value_field"),
doctype[self.date_field],
)
.where(
(doctype_item.docstatus == 1)
& (doctype.company.isin(self.filters.company))
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
)
.where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names)))
).run(as_dict=True)
self.get_groups()
@@ -300,8 +319,10 @@ class Analytics:
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
filters.update({"is_opening": "No"})
self.entries = frappe.get_all(
self.filters.doc_type, fields=[entity, value_field, self.date_field], filters=filters
self.entries = frappe.get_list(
self.filters.doc_type,
fields=[entity, value_field, self.date_field],
filters=filters,
)
def get_rows(self):

View File

@@ -262,7 +262,15 @@
},
"Belgium VAT 12%": {
"account_name": "VAT 12%",
"tax_rate": 12
"tax_rate": 12.00
},
"Belgium VAT 6%": {
"account_name": "VAT 6%",
"tax_rate": 6.00
},
"Belgium VAT 0%": {
"account_name": "VAT 0%",
"tax_rate": 0.00
}
},
@@ -4115,9 +4123,14 @@
},
"Japan": {
"Japan Tax": {
"account_name": "CT",
"tax_rate": 5.00
"Japan Tax 10%": {
"account_name": "CT 10%",
"tax_rate": 10.00,
"default": 1
},
"Japan Tax 8%": {
"account_name": "CT 8%",
"tax_rate": 8.00
}
},

View File

@@ -11,7 +11,6 @@ from frappe.model.naming import make_autoname, revert_series_if_last
from frappe.query_builder.functions import CurDate, Sum
from frappe.utils import cint, flt, get_link_to_form
from frappe.utils.data import add_days
from frappe.utils.jinja import render_template
class UnableToSelectBatchError(frappe.ValidationError):
@@ -225,10 +224,8 @@ class Batch(Document):
:return: The string that was generated.
"""
naming_series_prefix = _get_batch_prefix()
# validate_template(naming_series_prefix)
naming_series_prefix = render_template(str(naming_series_prefix), self.__dict__)
key = _make_naming_series_key(naming_series_prefix)
name = make_autoname(key)
name = make_autoname(key, doc=self)
return name

View File

@@ -506,6 +506,24 @@ class TestBatch(FrappeTestCase):
if not use_naming_series:
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", 0)
def test_naming_series_prefix_is_not_rendered_as_jinja(self):
from frappe.model.naming import InvalidNamingSeriesError
stock_settings = frappe.get_single("Stock Settings")
use_naming_series = cint(stock_settings.use_naming_series)
original_prefix = stock_settings.naming_series_prefix
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", 1)
frappe.set_value("Stock Settings", "Stock Settings", "naming_series_prefix", "{{ 7*7 }}")
try:
self.assertRaises(
InvalidNamingSeriesError, self.make_new_batch, "_Test Stock Item For Batch SSTI"
)
finally:
frappe.set_value("Stock Settings", "Stock Settings", "naming_series_prefix", original_prefix)
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", use_naming_series)
def make_new_batch(self, item_name=None, batch_id=None, do_not_insert=0):
batch = frappe.new_doc("Batch")
item = self.make_batch_item(item_name)

View File

@@ -263,8 +263,9 @@ def update_qty(bin_name, args):
# actual qty is already updated by processing current voucher
actual_qty = bin_details.actual_qty or 0.0
# actual qty is not up to date in case of backdated transaction
if future_sle_exists(args):
# actual qty is not up to date in case of backdated transactions
# or when cancellations are the most recent SLE
if future_sle_exists(args) or args.get("is_cancelled"):
actual_qty = get_actual_qty(args.get("item_code"), args.get("warehouse"))
ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty"))

View File

@@ -15,7 +15,7 @@ from frappe.query_builder import DocType
from frappe.query_builder.functions import Abs, Sum
from frappe.utils import cint, flt
from erpnext.accounts.party import get_due_date
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.stock_ledger import validate_reserved_stock
@@ -1405,8 +1405,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
doctype: {
"doctype": target_doctype,
"postprocess": update_details,
"field_no_map": ["taxes_and_charges", "set_warehouse"],
"field_map": {"shipping_address_name": "shipping_address"},
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"],
},
doctype + " Item": {
"doctype": target_doctype + " Item",

View File

@@ -740,14 +740,18 @@ $.extend(erpnext.item, {
if (!row.disabled) {
if (row.numeric_values) {
fieldtype = "Float";
desc =
"Min Value: " +
row.from_range +
" , Max Value: " +
row.to_range +
", in Increments of: " +
row.increment;
const all_are_int =
flt(row.from_range) === cint(row.from_range) &&
flt(row.to_range) === cint(row.to_range) &&
flt(row.increment) === cint(row.increment);
fieldtype = all_are_int ? "Int" : "Float";
const df = { fieldtype };
const options = all_are_int ? { inline: 1 } : { always_show_decimals: true, inline: 1 };
desc = __("Min Value: {0}, Max Value: {1}, in Increments of: {2}", [
frappe.format(row.from_range, df, options),
frappe.format(row.to_range, df, options),
frappe.format(row.increment, df, options),
]);
} else {
fieldtype = "Data";
desc = "";

View File

@@ -1050,6 +1050,44 @@ class TestPurchaseReceipt(FrappeTestCase):
pr.cancel()
def test_inter_company_purchase_receipt_does_not_inherit_party_fields(self):
"""
Party-derived fields on DN (from Customer) must not leak into the mapped PR.
"""
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
stock = make_purchase_receipt(warehouse="Stores - TCP1", company=company)
dn = create_delivery_note(
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=1,
rate=100,
warehouse="Stores - TCP1",
target_warehouse="Work In Progress - TCP1",
do_not_submit=True,
)
# Stamp customer-side party fields onto the DN
dn.tax_category = "_Test Tax Category 2"
dn.language = "ar"
dn.submit()
pr = make_inter_company_purchase_receipt(dn.name)
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier 2")
self.assertEqual(pr.tax_category or None, supplier.tax_category or None)
self.assertEqual(pr.language or None, supplier.language or None)
dn.cancel()
stock.cancel()
def test_lcv_for_internal_transfer(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note

View File

@@ -2169,7 +2169,16 @@ def get_type_of_transaction(parent_doc, child_row):
def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, warehouse=None) -> object:
frappe.has_permission("Serial and Batch Bundle", "write", throw=True)
doc = frappe.get_doc("Serial and Batch Bundle", bundle)
if doc.docstatus == 1:
doc.throw_error_message(
_("Serial and Batch Bundle {0} is submitted and its entries cannot be modified.").format(
frappe.bold(bundle)
)
)
doc.voucher_detail_no = child_row.name
doc.posting_date = parent_doc.posting_date
doc.posting_time = parent_doc.posting_time
@@ -2194,25 +2203,6 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh
return doc
@frappe.whitelist()
def update_serial_or_batch(bundle_id, serial_no=None, batch_no=None):
if batch_no and not serial_no:
if qty := frappe.db.get_value(
"Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty"
):
frappe.db.set_value(
"Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty", qty + 1
)
return
doc = frappe.get_cached_doc("Serial and Batch Bundle", bundle_id)
if not serial_no and not batch_no:
return
doc.append("entries", {"serial_no": serial_no, "batch_no": batch_no, "qty": 1})
doc.save(ignore_permissions=True)
def get_serial_and_batch_ledger(**kwargs):
kwargs = frappe._dict(kwargs)

View File

@@ -734,6 +734,60 @@ class TestSerialandBatchBundle(FrappeTestCase):
docstatus = frappe.db.get_value("Serial and Batch Bundle", bundle, "docstatus")
self.assertEqual(docstatus, 2)
def test_submitted_bundle_entries_cannot_be_mutated(self):
# A submitted Serial and Batch Bundle is the immutable source of truth for the stock
# ledger, live batch availability and repost/valuation replay. update_serial_batch_no_ledgers
# (which the whitelisted add_serial_batch_ledgers delegates to for an existing bundle) must
# refuse to rebuild -- and thereby inflate -- the quantities of an already submitted bundle.
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
update_serial_batch_no_ledgers,
)
item_code = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TAMPER-SBB-.#####",
}
).name
se = make_stock_entry(
item_code=item_code,
target="_Test Warehouse - _TC",
qty=10,
rate=100,
)
bundle = se.items[0].serial_and_batch_bundle
self.assertEqual(frappe.db.get_value("Serial and Batch Bundle", bundle, "docstatus"), 1)
original = frappe.db.get_value(
"Serial and Batch Entry", {"parent": bundle}, ["name", "batch_no", "qty"], as_dict=True
)
self.assertEqual(original.qty, 10)
# Attempt to forge the submitted bundle: keep the same batch but inflate qty. The guard
# fires immediately after the bundle is loaded (docstatus check), so child_row / parent_doc
# only need the minimal fields the function reads.
tampered_entries = [{"batch_no": original.batch_no, "qty": 1000}]
child_row = frappe._dict({"name": se.items[0].name})
parent_doc = frappe._dict({"posting_date": today(), "posting_time": nowtime()})
self.assertRaises(
frappe.ValidationError,
update_serial_batch_no_ledgers,
bundle,
tampered_entries,
child_row,
parent_doc,
)
# The on-disk quantity must be untouched by the rejected mutation attempt.
self.assertEqual(
frappe.db.get_value("Serial and Batch Entry", original.name, "qty"),
10,
)
def test_batch_duplicate_entry(self):
item_code = make_item(properties={"has_batch_no": 1}).name

View File

@@ -228,6 +228,13 @@ class StockEntry(StockController):
self.validate_warehouse()
self.validate_with_material_request()
# Disassembly rows are fully derived from the source manufacture entry / work order;
# verify the posted stock quantities have not been tampered with (raw-material minting).
# Must run after set_transfer_qty() so row.transfer_qty reflects qty * conversion_factor.
if self.purpose == "Disassemble":
self.validate_disassembly_quantities()
self.validate_batch()
self.validate_inspection()
self.validate_fg_completed_qty()
@@ -246,6 +253,7 @@ class StockEntry(StockController):
self.calculate_rate_and_amount()
self.validate_putaway_capacity()
self.validate_component_and_quantities()
self.validate_finished_good_serial_batch_for_work_order()
if not self.get("purpose") == "Manufacture":
# ignore scrap item wh difference and empty source/target wh
@@ -256,6 +264,83 @@ class StockEntry(StockController):
self.validate_same_source_target_warehouse_during_material_transfer()
self.validate_raw_materials_exists()
def validate_finished_good_serial_batch_for_work_order(self):
if not (
self.work_order
and self.pro_doc
and self.pro_doc.get("track_semi_finished_goods") != 1
and cint(
frappe.db.get_single_value(
"Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True
)
)
and (self.pro_doc.has_serial_no or self.pro_doc.has_batch_no)
):
return
for row in self.items:
if not row.is_finished_item:
continue
if self.check_invalid_serial_batch_nos_for_finished_good_item(row):
self.reset_serial_batch_on_fg_row(row)
frappe.msgprint(
_(
"Row {0}: Serial/Batch has been reset to values linked with Work Order {1}"
" because the previously selected serial/batch does not belong to this Work Order."
).format(row.idx, frappe.bold(self.work_order))
)
def check_invalid_serial_batch_nos_for_finished_good_item(self, row) -> bool:
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos_from_bundle
if self.pro_doc.has_serial_no:
serial_nos = get_serial_nos(row.serial_no) if row.serial_no else []
if not serial_nos and row.serial_and_batch_bundle:
serial_nos = get_serial_nos_from_bundle(row.serial_and_batch_bundle)
if serial_nos:
valid_serial_nos = frappe.get_all(
"Serial No",
filters={"name": ("in", serial_nos), "work_order": self.work_order},
pluck="name",
)
return bool(set(serial_nos) - set(valid_serial_nos))
else:
return True
if self.pro_doc.has_batch_no:
batch_nos = [row.batch_no] if row.batch_no else []
if not batch_nos and row.serial_and_batch_bundle:
batch_nos = list(get_batches_from_bundle(row.serial_and_batch_bundle).keys())
if batch_nos:
valid_batch_nos = frappe.get_all(
"Batch",
filters={"name": ("in", batch_nos), "reference_name": self.work_order},
pluck="name",
)
return bool(set(batch_nos) - set(valid_batch_nos))
else:
return True
def reset_serial_batch_on_fg_row(self, row):
item_details = frappe._dict(
{
"item_code": row.item_code,
"t_warehouse": row.t_warehouse,
"qty": row.qty,
}
)
row.serial_no = None
row.batch_no = None
row.serial_and_batch_bundle = None
if self.pro_doc.has_serial_no:
self.set_serial_no_batch_for_finished_good()
elif self.pro_doc.has_batch_no:
self.set_batchwise_finished_goods(item_details, None, existing_row=row)
def validate_repack_entry(self):
if self.purpose != "Repack":
return
@@ -602,7 +687,7 @@ class StockEntry(StockController):
amount += additional_cost_amt
project = frappe.get_doc("Project", self.project)
project.total_consumed_material_cost = amount
project.save()
project.save(ignore_permissions=True)
def validate_item(self):
stock_items = self.get_stock_items()
@@ -853,6 +938,93 @@ class StockEntry(StockController):
title=_("Excess Disassembly"),
)
def validate_disassembly_quantities(self):
self.validate_finished_good_consumption()
self.validate_materials_against_source()
def validate_finished_good_consumption(self):
"""The finished good consumed (in stock UOM) must equal the quantity to disassemble."""
precision = frappe.get_precision("Stock Entry Detail", "transfer_qty")
tolerance = _qty_tolerance(precision)
fg_stock_qty = sum(flt(row.transfer_qty) for row in self.items if row.is_finished_item)
fg_completed_qty = flt(self.fg_completed_qty)
if abs(flt(fg_stock_qty, precision) - flt(fg_completed_qty, precision)) > tolerance:
frappe.throw(
_(
"Finished good quantity being consumed ({0} in stock UOM) must equal the quantity "
"to disassemble ({1}). Do not change the UOM, conversion factor or quantity of the "
"finished good row."
).format(flt(fg_stock_qty, precision), flt(fg_completed_qty, precision)),
title=_("Invalid Disassembly Quantity"),
)
def validate_materials_against_source(self):
"""Every non-finished-good row's posted stock qty must equal the source qty x scale."""
scale_factor = self._get_disassembly_scale_factor()
if not scale_factor:
# Standalone BOM disassembly: no source entry to scale against. The finished-good
# invariant above still applies; raw-material amounts come from the BOM.
return
source_rows = self.get_items_from_manufacture_stock_entry()
source_by_name = {row.name: row for row in source_rows if row.get("name")}
source_by_item = defaultdict(float)
for row in source_rows:
source_by_item[row.item_code] += flt(row.transfer_qty)
precision = frappe.get_precision("Stock Entry Detail", "transfer_qty")
tolerance = _qty_tolerance(precision)
for row in self.items:
if row.is_finished_item:
continue # covered by validate_finished_good_consumption
if row.ste_detail and row.ste_detail in source_by_name:
expected = flt(source_by_name[row.ste_detail].transfer_qty) * scale_factor
elif row.item_code in source_by_item:
expected = source_by_item[row.item_code] * scale_factor
else:
frappe.throw(
_(
"Row #{0}: Item {1} is not part of the source manufacture entry and cannot be "
"added to this disassembly."
).format(row.idx, frappe.bold(row.item_code)),
title=_("Invalid Disassembly Item"),
)
if abs(flt(row.transfer_qty, precision) - flt(expected, precision)) > tolerance:
frappe.throw(
_(
"Row #{0}: Item {1} quantity ({2} in stock UOM) does not match the quantity "
"derived from the source ({3}). Do not change the UOM, conversion factor or "
"quantity of disassembly rows."
).format(
row.idx,
frappe.bold(row.item_code),
flt(row.transfer_qty, precision),
flt(expected, precision),
),
title=_("Invalid Disassembly Quantity"),
)
def _get_disassembly_scale_factor(self) -> float:
disassemble_qty = flt(self.fg_completed_qty)
if self.source_stock_entry:
source_fg_qty = flt(
frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")
)
elif self.work_order:
source_fg_qty = flt(frappe.db.get_value("Work Order", self.work_order, "produced_qty"))
else:
return 0.0
if not source_fg_qty:
return 0.0
return disassemble_qty / source_fg_qty
def check_if_operations_completed(self):
"""Check if Time Sheets are completed against before manufacturing to capture operating costs."""
prod_order = frappe.get_doc("Work Order", self.work_order)
@@ -2465,15 +2637,16 @@ class StockEntry(StockController):
else:
self.add_finished_goods(args, item)
def set_batchwise_finished_goods(self, args, item):
def set_batchwise_finished_goods(self, args, item, existing_row=None):
batches = get_empty_batches_based_work_order(self.work_order, self.pro_doc.production_item)
if not batches:
self.add_finished_goods(args, item)
if not existing_row:
self.add_finished_goods(args, item)
else:
self.add_batchwise_finished_good(batches, args, item)
self.add_batchwise_finished_good(batches, args, item, existing_row=existing_row)
def add_batchwise_finished_good(self, batches, args, item):
def add_batchwise_finished_good(self, batches, args, item, existing_row=None):
qty = flt(self.fg_completed_qty)
row = frappe._dict({"batches_to_be_consume": defaultdict(float)})
@@ -2482,7 +2655,7 @@ class StockEntry(StockController):
if not row.batches_to_be_consume:
return
id = create_serial_and_batch_bundle(
_id = create_serial_and_batch_bundle(
self,
row,
frappe._dict(
@@ -2492,9 +2665,13 @@ class StockEntry(StockController):
}
),
)
args["serial_and_batch_bundle"] = id
self.add_finished_goods(args, item)
if existing_row:
existing_row.serial_and_batch_bundle = _id
existing_row.use_serial_batch_fields = 0
else:
args["serial_and_batch_bundle"] = _id
args["use_serial_batch_fields"] = 0
self.add_finished_goods(args, item)
def add_finished_goods(self, args, item):
self.add_to_stock_entry_detail({item.name: args}, bom_no=self.bom_no)
@@ -3304,6 +3481,12 @@ def move_sample_to_retention_warehouse(company, items):
return stock_entry.as_dict()
def _qty_tolerance(precision: int) -> float:
"""One unit at the column's precision -- absorbs float rounding without letting a real
(whole-unit) quantity divergence slip through."""
return 1.0 / (10**precision)
@frappe.whitelist()
def make_stock_in_entry(source_name, target_doc=None):
def set_missing_values(source, target):

View File

@@ -1,11 +1,13 @@
frappe.listview_settings["Stock Entry"] = {
add_fields: [
"`tabStock Entry`.`from_warehouse`",
"`tabStock Entry`.`to_warehouse`",
"`tabStock Entry`.`purpose`",
"`tabStock Entry`.`work_order`",
"`tabStock Entry`.`bom_no`",
"`tabStock Entry`.`is_return`",
"from_warehouse",
"to_warehouse",
"purpose",
"work_order",
"bom_no",
"is_return",
"add_to_transit",
"per_transferred",
],
get_indicator: function (doc) {
if (doc.is_return === 1 && doc.purpose === "Material Transfer for Manufacture") {
@@ -16,6 +18,17 @@ frappe.listview_settings["Stock Entry"] = {
];
} else if (doc.docstatus === 0) {
return [__("Draft"), "red", "docstatus,=,0"];
} else if (
doc.docstatus === 1 &&
doc.add_to_transit === 1 &&
doc.purpose === "Material Transfer" &&
doc.per_transferred < 100
) {
return [
__("In Transit"),
"yellow",
"docstatus,=,1|add_to_transit,=,1|purpose,=,Material Transfer|per_transferred,<,100",
];
} else if (doc.purpose === "Send to Warehouse" && doc.per_transferred < 100) {
// not delivered & overdue
return [__("Goods In Transit"), "grey", "per_transferred,<,100"];

View File

@@ -235,7 +235,15 @@ class TestStockEntry(FrappeTestCase):
self.assertEqual(transit_entry.name, end_transit_entry.items[0].against_stock_entry)
self.assertEqual(transit_entry.items[0].name, end_transit_entry.items[0].ste_detail)
# create add to transit
transit_entry.reload()
self.assertEqual(transit_entry.per_transferred, 0)
end_transit_entry.to_warehouse = "Test To Warehouse - _TC"
end_transit_entry.items[0].t_warehouse = "Test To Warehouse - _TC"
end_transit_entry.save().submit()
transit_entry.reload()
self.assertEqual(transit_entry.per_transferred, 100)
def test_material_receipt_gl_entry(self):
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
@@ -2236,6 +2244,47 @@ class TestStockEntry(FrappeTestCase):
se.save()
se.submit()
def test_disassemble_blocks_finished_good_qty_tampering(self):
# A disassembly consuming N finished goods must consume exactly N (in stock UOM).
# Switching the finished-good row to a larger UOM with a tiny conversion_factor previously
# let a user consume ~0 finished goods while still producing the full raw materials --
# minting inventory. The quantity invariant must reject this.
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
fg_item = make_item("_Disassemble Mint FG", properties={"is_stock_item": 1}).name
rm_item1 = make_item("_Disassemble Mint RM1", properties={"is_stock_item": 1}).name
rm_item2 = make_item("_Disassemble Mint RM2", properties={"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
# Give the finished good a non-stock UOM. When uom == stock_uom the system resets the
# conversion_factor to 1, so the tamper is only possible (and worth guarding) on a
# non-stock UOM, where the user-supplied conversion_factor is preserved.
if not frappe.db.get_value("UOM Conversion Detail", {"parent": fg_item, "uom": "Box"}):
item_doc = frappe.get_doc("Item", fg_item)
item_doc.append("uoms", {"uom": "Box", "conversion_factor": 0.01})
item_doc.save(ignore_permissions=True)
make_stock_entry(item_code=fg_item, target=warehouse, qty=100, purpose="Material Receipt")
bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name
se = make_stock_entry(item_code=fg_item, qty=100, purpose="Disassemble", do_not_save=True)
se.from_bom = 1
se.use_multi_level_bom = 1
se.bom_no = bom_no
se.fg_completed_qty = 100
se.from_warehouse = warehouse
se.to_warehouse = warehouse
se.get_items()
# Tamper the finished-good row: a tiny conversion factor on the larger UOM means only
# 100 * 0.01 = 1 unit is actually consumed, while raw materials are still produced at full
# quantity. The finished-good consumption invariant must reject the save.
fg_row = next(d for d in se.items if d.is_finished_item)
fg_row.uom = "Box"
fg_row.conversion_factor = 0.01
self.assertRaises(frappe.ValidationError, se.save)
def test_raw_material_missing_validation(self):
original_value = frappe.db.get_single_value("Manufacturing Settings", "material_consumption")
frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 0)

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_copy": 1,
"autoname": "MAT-SLE-.YYYY.-.#####",
"creation": "2013-01-29 19:25:42",
@@ -204,7 +205,7 @@
{
"fieldname": "valuation_rate",
"fieldtype": "Currency",
"label": "Valuation Rate",
"label": "Average Rate",
"oldfieldname": "valuation_rate",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
@@ -362,11 +363,11 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-04 09:59:15.546556",
"modified": "2026-05-26 19:07:43.537450",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Ledger Entry",
"naming_rule": "Expression (old style)",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -9,14 +9,14 @@ from frappe.utils import add_to_date, cint, cstr, flt, get_datetime, now
import erpnext
from erpnext.accounts.utils import get_company_default
from erpnext.controllers.stock_controller import StockController, create_repost_item_valuation_entry
from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_available_serial_nos,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_stock_balance
from erpnext.stock.utils import get_incoming_rate, get_stock_balance
class OpeningEntryAccountError(frappe.ValidationError):
@@ -842,22 +842,6 @@ class StockReconciliation(StockController):
sl_entries.append(args)
def update_valuation_rate_for_serial_no(self):
for d in self.items:
if not d.serial_no:
continue
serial_nos = get_serial_nos(d.serial_no)
self.update_valuation_rate_for_serial_nos(d, serial_nos)
def update_valuation_rate_for_serial_nos(self, row, serial_nos):
valuation_rate = row.valuation_rate if self.docstatus == 1 else row.current_valuation_rate
if valuation_rate is None:
return
for d in serial_nos:
frappe.db.set_value("Serial No", d, "purchase_rate", valuation_rate)
def get_sle_for_items(self, row, serial_nos=None, current_bundle=True):
"""Insert Stock Ledger Entries"""
@@ -1011,11 +995,6 @@ class StockReconciliation(StockController):
d.quantity_difference = flt(d.qty) - flt(d.current_qty)
d.amount_difference = flt(d.amount) - flt(d.current_amount)
def get_items_for(self, warehouse):
self.items = []
for item in get_items(warehouse, self.posting_date, self.posting_time, self.company):
self.append("items", item)
def submit(self):
if len(self.items) > 100:
msgprint(
@@ -1038,145 +1017,6 @@ class StockReconciliation(StockController):
else:
self._cancel()
def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation):
if row.current_qty == 0:
return
new_sle = frappe.get_doc(self.get_sle_for_items(row))
new_sle.actual_qty = row.current_qty * -1
new_sle.valuation_rate = row.current_valuation_rate
new_sle.serial_and_batch_bundle = row.current_serial_and_batch_bundle
new_sle.flags.ignore_permissions = 1
new_sle.submit()
creation = add_to_date(sle_creation, seconds=-1)
new_sle.db_set("creation", creation)
if not frappe.db.exists(
"Repost Item Valuation",
{"item": row.item_code, "warehouse": row.warehouse, "docstatus": 1, "status": "Queued"},
):
create_repost_item_valuation_entry(
{
"based_on": "Item and Warehouse",
"item_code": row.item_code,
"warehouse": row.warehouse,
"company": self.company,
"allow_negative_stock": 1,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
def has_negative_stock_allowed(self):
allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
if allow_negative_stock:
return True
if any(
((d.serial_and_batch_bundle or d.batch_no) and flt(d.qty) == flt(d.current_qty))
for d in self.items
):
allow_negative_stock = True
return allow_negative_stock
def get_current_qty_for_serial_or_batch(self, row, sle_creation):
doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle)
current_qty = 0.0
if doc.has_serial_no:
current_qty = self.get_current_qty_for_serial_nos(doc, sle_creation)
elif doc.has_batch_no:
current_qty = self.get_current_qty_for_batch_nos(doc, sle_creation)
return abs(current_qty)
def get_current_qty_for_serial_nos(self, doc, sle_creation):
serial_nos_details = get_available_serial_nos(
frappe._dict(
{
"item_code": doc.item_code,
"warehouse": doc.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"creation": sle_creation,
"voucher_no": self.name,
"ignore_warehouse": 1,
}
)
)
if not serial_nos_details:
return 0.0
doc.delete_serial_batch_entries()
current_qty = 0.0
for serial_no_row in serial_nos_details:
current_qty += 1
doc.append(
"entries",
{
"serial_no": serial_no_row.serial_no,
"qty": -1,
"warehouse": doc.warehouse,
"batch_no": serial_no_row.batch_no,
},
)
doc.set_incoming_rate(save=True)
doc.calculate_qty_and_amount(save=True)
doc.db_update_all()
return current_qty
def get_current_qty_for_batch_nos(self, doc, sle_creation):
current_qty = 0.0
precision = doc.entries[0].precision("qty")
for d in doc.entries:
qty = (
get_batch_qty(
d.batch_no,
doc.warehouse,
creation=sle_creation,
posting_date=doc.posting_date,
posting_time=doc.posting_time,
ignore_voucher_nos=[doc.voucher_no],
for_stock_levels=True,
consider_negative_batches=True,
do_not_check_future_batches=True,
)
or 0
) * -1
if flt(d.qty, precision) != flt(qty, precision):
d.db_set("qty", qty)
current_qty += qty
return current_qty
def get_batch_qty_for_stock_reco(
item_code, warehouse, batch_no, posting_date, posting_time, voucher_no, sle_creation
):
qty = (
get_batch_qty(
batch_no,
warehouse,
item_code,
creation=sle_creation,
posting_date=posting_date,
posting_time=posting_time,
ignore_voucher_nos=[voucher_no],
for_stock_levels=True,
consider_negative_batches=True,
do_not_check_future_batches=True,
)
or 0
)
return flt(qty)
@frappe.whitelist()
def get_items(warehouse, posting_date, posting_time, company, item_code=None, ignore_empty_stock=False):

View File

@@ -11,6 +11,7 @@ from frappe.query_builder.functions import Abs, Count
from frappe.utils import cint, date_diff, flt, get_datetime
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
from erpnext.stock.valuation import round_off_if_near_zero
Filters = frappe._dict
@@ -936,9 +937,7 @@ class FIFOSlots:
)
)
for field in ["item_code"]:
if self.filters.get(field):
query = query.where(sle[field] == self.filters.get(field))
query = self._apply_filter(query, sle, "item_code")
if self.filters.get("warehouse"):
query = self._get_warehouse_conditions(sle, query)
@@ -985,9 +984,7 @@ class FIFOSlots:
)
)
for field in ["item_code"]:
if self.filters.get(field):
query = query.where(sle[field] == self.filters.get(field))
query = self._apply_filter(query, sle, "item_code")
if self.filters.get("warehouse"):
query = self._get_warehouse_conditions(sle, query)
@@ -1020,27 +1017,25 @@ class FIFOSlots:
"has_batch_no",
)
if self.filters.get("item_code"):
item = item.where(item_table.item_code == self.filters.get("item_code"))
item = self._apply_filter(item, item_table, "item_code")
if self.filters.get("brand"):
item = item.where(item_table.brand == self.filters.get("brand"))
return item
def _apply_filter(self, query, table, fieldname: str):
filter_value = self.filters.get(fieldname)
if not filter_value:
return query
if isinstance(filter_value, list | tuple | set):
return query.where(table[fieldname].isin(filter_value))
return query.where(table[fieldname] == filter_value)
def _get_warehouse_conditions(self, sle, sle_query) -> str:
warehouse = frappe.qb.DocType("Warehouse")
lft, rgt = frappe.db.get_value("Warehouse", self.filters.get("warehouse"), ["lft", "rgt"])
warehouse_results = (
frappe.qb.from_(warehouse)
.select("name")
.where((warehouse.lft >= lft) & (warehouse.rgt <= rgt))
.run()
)
warehouse_results = [x[0] for x in warehouse_results]
return sle_query.where(sle.warehouse.isin(warehouse_results))
return apply_warehouse_filter(sle_query, sle, self.filters)
def prepare_stock_reco_voucher_wise_count(self):
self.stock_reco_voucher_wise_count = frappe._dict()

View File

@@ -129,6 +129,18 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(queue[0][0], 10.0)
self.assertEqual(queue[1][0], 10.0)
def test_item_filter_supports_multi_select_values(self):
bundle = frappe.qb.DocType("Serial and Batch Bundle")
query = frappe.qb.from_(bundle).select(bundle.name)
filtered_query = FIFOSlots(frappe._dict(item_code=["Item A"]), [])._apply_filter(
query, bundle, "item_code"
)
sql = filtered_query.get_sql()
self.assertIn(" IN ", sql)
self.assertNotIn("=[", sql)
def test_basic_stock_reconciliation(self):
"""
Ledger (same wh): [+30, reco reset >> 50, -10]

View File

@@ -109,10 +109,11 @@ def execute(filters=None):
if sle.serial_no:
update_available_serial_nos(available_serial_nos, sle)
if sle.actual_qty:
if sle.actual_qty < 0:
sle["in_out_rate"] = flt(sle.stock_value_difference / sle.actual_qty, precision)
sle["incoming_rate"] = 0
elif sle.voucher_type == "Stock Reconciliation":
elif sle.voucher_type == "Stock Reconciliation" and sle.actual_qty < 0:
sle["in_out_rate"] = sle.valuation_rate
if (
@@ -193,7 +194,7 @@ def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict, filte
new_sle.update(row)
new_sle.update(
{
"in_out_rate": flt(new_sle.stock_value_difference / row.qty) if row.qty else 0,
"in_out_rate": flt(new_sle.stock_value_difference / row.qty) if row.qty < 0 else 0,
"in_qty": row.qty if row.qty > 0 else 0,
"out_qty": row.qty if row.qty < 0 else 0,
"qty_after_transaction": qty_before_transaction + row.qty,
@@ -375,7 +376,7 @@ def get_columns(filters):
"convertible": "rate",
},
{
"label": _("Valuation Rate"),
"label": _("Outgoing Rate"),
"fieldname": "in_out_rate",
"fieldtype": filters.valuation_field_type,
"width": 140,

View File

@@ -887,7 +887,7 @@ class update_entries_after:
# Only run in reposting
self.get_serialized_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and has_correct_data(sle):
self.wh_data.qty_after_transaction = sle.qty_after_transaction
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(
@@ -2517,3 +2517,28 @@ def get_incoming_rate_for_serial_and_batch(item_code, row, sn_obj):
@frappe.request_cache
def is_repack_entry(stock_entry_id):
return frappe.get_cached_value("Stock Entry", stock_entry_id, "purpose") == "Repack"
def has_correct_data(sle):
previous_sle = get_previous_sle(
{
"item_code": sle.item_code,
"warehouse": sle.warehouse,
"posting_date": sle.posting_date,
"posting_time": sle.posting_time,
"creation": sle.creation,
"sle": sle.name,
}
)
if not previous_sle:
return True
previous_qty = previous_sle.get("qty_after_transaction") or 0
if previous_qty and not frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": sle.voucher_detail_no, "is_cancelled": 0, "actual_qty": ("<", 0)},
):
return False
return True

View File

@@ -218,11 +218,13 @@ def get_issue_list(doctype, txt, filters, limit_start, limit_page_length=20, ord
@frappe.whitelist()
def set_multiple_status(names, status):
for name in json.loads(names):
frappe.db.set_value("Issue", name, "status", status)
set_status(name, status)
@frappe.whitelist()
def set_status(name, status):
frappe.has_permission("Issue", "write", name, throw=True)
frappe.db.set_value("Issue", name, "status", status)

View File

@@ -64,21 +64,6 @@ def get_tasks(project, start=0, search=None, item_status=None):
return list(filter(lambda x: not x.parent_task, tasks))
@frappe.whitelist()
def get_task_html(project, start=0, item_status=None):
return frappe.render_template(
"erpnext/templates/includes/projects/project_tasks.html",
{
"doc": {
"name": project,
"project_name": project,
"tasks": get_tasks(project, start, item_status=item_status),
}
},
is_path=True,
)
def get_timesheets(project, start=0, search=None):
filters = {"project": project}
if search:
@@ -104,15 +89,6 @@ def get_timesheets(project, start=0, search=None):
return timesheets
@frappe.whitelist()
def get_timesheet_html(project, start=0):
return frappe.render_template(
"erpnext/templates/includes/projects/project_timesheets.html",
{"doc": {"timesheets": get_timesheets(project, start)}},
is_path=True,
)
def get_attachments(project):
return frappe.get_all(
"File",

View File

@@ -1,11 +1,11 @@
{% extends "templates/web.html" %}
{% block title %} {{ doc.name }} {% endblock %}
{% block title %} {{ doc.name|e }} {% endblock %}
{% block breadcrumbs %}
<div class="page-breadcrumbs" data-html-block="breadcrumbs">
<div class="page-breadcrumbs container" data-html-block="breadcrumbs">
<ul class="breadcrumb">
<li>
<span class="fa fa-angle-left"></span>
<a href="/projects?project={{ doc.project }}">{{ doc.project }}</a>
<a href="/projects?project={{ doc.project|e }}">{{ doc.project|e }}</a>
</li>
</ul>
</div>
@@ -13,16 +13,7 @@
{% block page_content %}
<div class="row">
<div class=" col-sm-8 ">
<h1> {{ doc.subject }} </h1>
</div>
<div class="col-sm-4">
<div class="page-header-actions-block" data-html-block="header-actions">
<button type="submit" class="btn btn-primary btn-sm btn-form-submit">
{{ __("Update") }}</button>
<a href="tasks" class="btn btn-light btn-sm">
{{ __("Cancel") }}</a>
</div>
<h1> {{ doc.subject|e }} </h1>
</div>
</div>
@@ -31,50 +22,44 @@
<input type="hidden" name="web_form" value="tasks">
<input type="hidden" name="doctype" value="Task">
<input type="hidden" name="name" value="TASK00056">
<input type="hidden" name="name" value="{{ doc.name|e }}">
<div class="row">
<div class="col-sm-12" style="max-width: 500px;">
<div class="form-group">
<label for="project" class="control-label text-muted small">{{ __("Project") }}</label>
<input type="text" class="form-control" name="project" readonly value= "{{ doc.project }}">
<label for="project" class="control-label text-muted small">{{ _("Project") }}</label>
<input type="text" class="form-control" name="project" readonly value= "{{ doc.project or ""|e }}">
</div>
<div class="form-group">
<label for="subject" class="control-label text-muted small">{{ __("Subject") }}</label>
<input type="text" class="form-control" name="subject" readonly value="{{ doc.subject }}">
<label for="subject" class="control-label text-muted small">{{ _("Subject") }}</label>
<input type="text" class="form-control" name="subject" readonly value="{{ doc.subject or ""|e }}">
</div>
<div class="form-group">
<label for="description" class="control-label text-muted small">{{ __("Details") }}</label>
<textarea class="form-control" style="height: 200px;" name="description">{{ doc.description }}</textarea>
<label for="description" class="control-label text-muted small">{{ _("Details") }}</label>
<textarea class="form-control" style="height: 200px;" name="description" readonly>{{ doc.description or ""|e }}</textarea>
</div>
<div class="form-group">
<label for="priority" class="control-label text-muted small">{{ __("Priority") }}</label>
<input type="text" class="form-control" name="priority" readonly value="{{ doc.priority }}">
<label for="priority" class="control-label text-muted small">{{ _("Priority") }}</label>
<input type="text" class="form-control" name="priority" readonly value="{{ doc.priority or ""|e }}">
</div>
<div class="form-group">
<label for="exp_start_date" class="control-label text-muted small">{{ __("Expected Start Date") }}</label>
<input type="text" class="form-control hasDatepicker" name="exp_start_date" readonly value="{{ doc.exp_start_date }}">
<label for="exp_start_date" class="control-label text-muted small">{{ _("Expected Start Date") }}</label>
<input type="text" class="form-control hasDatepicker" name="exp_start_date" readonly value="{{ doc.exp_start_date or ""|e }}">
</div>
<div class="form-group">
<label for="exp_end_date" class="control-label text-muted small">{{ __("Expected End Date") }}</label>
<input type="text" class="form-control hasDatepicker" name="exp_end_date" readonly value="{{ doc.exp_end_date }}">
<label for="exp_end_date" class="control-label text-muted small">{{ _("Expected End Date") }}</label>
<input type="text" class="form-control hasDatepicker" name="exp_end_date" readonly value="{{ doc.exp_end_date or ""|e }}">
</div>
<div class="form-group">
<label for="status" class="control-label text-muted small">{{ __("Status") }}</label>
<select class="form-control" name="status" id="status" data-label="Status" data-fieldtype="Select">
<option value="Open" selected="selected">
{{ __("Open") }}</option><option value="Working">
{{ __("Working") }}</option><option value="Pending Review">
{{ __("Pending Review") }}</option><option value="Overdue">
{{ __("Overdue") }}</option><option value="Closed">
{{ __("Closed") }}</option><option value="Cancelled">
{{ __("Cancelled") }}</option>
<label for="status" class="control-label text-muted small">{{ _("Status") }}</label>
<select class="form-control" name="status" id="status" data-label="Status" data-fieldtype="Select" disabled>
<option value="{{ doc.status|e }}" selected="selected">{{ doc.status|e }}</option>
</select>
</div>
</div>
@@ -83,68 +68,13 @@
</div>
<div class="comments">
<h3>{{ __("Comments") }}</h3>
<h3>{{ _("Comments") }}</h3>
<div class="no-comment">
{% for comment in comments %}
<p class="text-muted">{{comment.sender_full_name}}:
{{comment.subject}} {{ __("on") }} {{comment.creation.strftime('%Y-%m-%d')}}</p>
<p class="text-muted">{{comment.comment_email}}:
{{comment.content|e}} {{ _("on") }} {{comment.creation.strftime('%Y-%m-%d')}}</p>
{% endfor %}
</div>
<div class="comment-form-wrapper">
<a class="add-comment btn btn-light btn-sm">{{ __("Add Comment") }}</a>
<div style="display: none;" id="comment-form">
<p>{{ __("Add Comment") }}</p>
<form>
<fieldset>
<textarea class="form-control" name="comment" rows="5" placeholder="Comment"></textarea>
<p>
<button class="btn btn-primary btn-sm" id="submit-comment">{{ __("Submit") }}</button>
</p>
</fieldset>
</form>
</div>
</div>
</div>
<script>
frappe.ready(function() {
var n_comments = $(".comment-row").length;
$(".add-comment").click(function() {
$(this).toggle(false);
$("#comment-form").toggle();
$("#comment-form textarea").val("");
})
$("#submit-comment").click(function() {
var args = {
comment_by_fullname: "test",
comment_by: "admin@localhost.com",
comment: $("[name='comment']").val(),
reference_doctype: "Task",
reference_name: "TASK00069",
comment_type: "Comment",
route: "tasks",
}
frappe.call({
btn: this,
type: "POST",
method: "frappe.templates.includes.comments.comments.add_comment",
args: args,
callback: function(r) {
if(r.exc) {
if(r._server_messages)
frappe.msgprint(r._server_messages);
} else {
$(r.message).appendTo("#comment-list");
$(".no-comment, .add-comment").toggle(false);
$("#comment-form")
.replaceWith('<div class="text-muted">Thank you for your comment!</div>')
}
}
})
return false;
})
});
</script>
{% endblock %}

View File

@@ -5,11 +5,12 @@ def get_context(context):
context.no_cache = 1
task = frappe.get_doc("Task", frappe.form_dict.task)
task.check_permission()
context.comments = frappe.get_all(
"Communication",
filters={"reference_name": task.name, "comment_type": "comment"},
fields=["subject", "sender_full_name", "communication_date"],
"Comment",
filters={"reference_doctype": "Task", "reference_name": task.name, "comment_type": "Comment"},
fields=["content", "comment_email", "creation"],
)
context.doc = task

View File

@@ -12,10 +12,13 @@ no_cache = 1
def get_context(context):
is_enabled = frappe.db.get_single_value("Appointment Booking Settings", "enable_scheduling")
if is_enabled:
return context
else:
handle_appointment_booking_disabled()
return context
def handle_appointment_booking_disabled():
if not frappe.get_single_value("Appointment Booking Settings", "enable_scheduling"):
frappe.redirect_to_message(
_("Appointment Scheduling Disabled"),
_("Appointment Scheduling has been disabled for this site"),
@@ -27,9 +30,9 @@ def get_context(context):
@frappe.whitelist(allow_guest=True)
def get_appointment_settings():
settings = frappe.get_cached_value(
handle_appointment_booking_disabled()
settings = frappe.get_single_value(
"Appointment Booking Settings",
None,
["advance_booking_days", "appointment_duration", "success_redirect_url"],
as_dict=True,
)
@@ -38,6 +41,7 @@ def get_appointment_settings():
@frappe.whitelist(allow_guest=True)
def get_timezones():
handle_appointment_booking_disabled()
import pytz
return pytz.all_timezones
@@ -46,6 +50,7 @@ def get_timezones():
@frappe.whitelist(allow_guest=True)
def get_appointment_slots(date, timezone):
# Convert query to local timezones
handle_appointment_booking_disabled()
format_string = "%Y-%m-%d %H:%M:%S"
query_start_time = datetime.datetime.strptime(date + " 00:00:00", format_string)
query_end_time = datetime.datetime.strptime(date + " 23:59:59", format_string)
@@ -54,7 +59,11 @@ def get_appointment_slots(date, timezone):
now = convert_to_guest_timezone(timezone, datetime.datetime.now())
# Database queries
settings = frappe.get_doc("Appointment Booking Settings")
settings = frappe.get_single_value(
"Appointment Booking Settings",
["holiday_list", "appointment_duration", "number_of_agents", "availability_of_slots"],
as_dict=True,
)
holiday_list = frappe.get_doc("Holiday List", settings.holiday_list)
timeslots = get_available_slots_between(query_start_time, query_end_time, settings)
@@ -95,6 +104,7 @@ def get_available_slots_between(query_start_time, query_end_time, settings):
@frappe.whitelist(allow_guest=True)
def create_appointment(date, time, tz, contact):
handle_appointment_booking_disabled()
format_string = "%Y-%m-%d %H:%M:%S"
scheduled_time = datetime.datetime.strptime(date + " " + time, format_string)
# Strip tzinfo from datetime objects since it's handled by the doctype