Compare commits

..

129 Commits

Author SHA1 Message Date
mergify[bot]
fe3f44f643 ci: set disabledLabels and context for greptile (backport #55883) (#55884)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-06-13 19:03:30 +00:00
mergify[bot]
8b3a0fe045 fix(Lead): stop storing Gravatar image URLs for Leads (backport #55880) (#55881)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
fix(Lead): stop storing Gravatar image URLs for Leads (#55880)
2026-06-13 18:19:13 +00:00
Mihir Kandoi
3d6a4eebee Merge pull request #55864 from frappe/mergify/bp/version-15-hotfix/pr-55853
fix: pass source cost center to target cost center (backport #55853)
2026-06-13 23:43:10 +05:30
rohitwaghchaure
0a3c53b16d Merge pull request #55875 from frappe/mergify/bp/version-15-hotfix/pr-55874
fix: permission in bom compare tool (backport #55874)
2026-06-13 19:40:36 +05:30
Rohit Waghchaure
ef3046dca2 fix: permission in bom compare tool
(cherry picked from commit e6fdb3702a)
2026-06-13 13:41:00 +00:00
rohitwaghchaure
5addc66301 Merge pull request #55873 from frappe/mergify/bp/version-15-hotfix/pr-55872
fix: pemission for whitelist functions (backport #55872)
2026-06-13 19:03:18 +05:30
rohitwaghchaure
08d9b8275d fix: BOM Creator by removing redundant permissions
Removed unnecessary permission checks for whitelist functions and cleaned up the code.
2026-06-13 18:46:40 +05:30
rohitwaghchaure
46b3e0c385 fix: clearance date update with permission checks
Refactor update_clearance_date to check permissions for payment documents before processing entries.
2026-06-13 18:43:12 +05:30
Rohit Waghchaure
559c95c8a8 fix: pemission for whitelist functions
(cherry picked from commit dd56e80512)

# Conflicts:
#	erpnext/accounts/doctype/bank_clearance/bank_clearance.py
#	erpnext/manufacturing/doctype/bom_creator/bom_creator.py
#	erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py
2026-06-13 12:45:31 +00:00
mergify[bot]
d4605771da fix: opportunity creation from contact us page (backport #55841) (#55866)
* fix: opportunity creation from contact us page (#55841)

(cherry picked from commit c933e34914)

# Conflicts:
#	erpnext/crm/doctype/crm_settings/crm_settings.json
#	erpnext/templates/utils.py

* chore: resolve conflict (crm_settings.json)

* chore: resolve conflicts (send_message)

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-13 09:08:30 +00:00
SandraFrappe
209977f6a3 fix: pass source cost center to target cost center
(cherry picked from commit 9ea766fc10)
2026-06-12 15:27:54 +00:00
rohitwaghchaure
eec11ac7b2 Merge pull request #55828 from rohitwaghchaure/fixed-bom-creator-security-issues-v15
fix: multiple issues related to BOM Creator
2026-06-12 19:52:19 +05:30
Rohit Waghchaure
7c78aa6e5d fix: converted whitelist non class methods to class methods 2026-06-12 19:37:41 +05:30
Rohit Waghchaure
f4e6f14342 fix: multiple issues related to BOM Creator 2026-06-12 19:01:56 +05:30
rohitwaghchaure
48886467ec Merge pull request #55861 from frappe/mergify/bp/version-15-hotfix/pr-55858
fix: permissions in workstation file (backport #55844) (backport #55858)
2026-06-12 19:00:06 +05:30
rohitwaghchaure
baafb95e74 chore: fix conflicts 2026-06-12 18:36:53 +05:30
rohitwaghchaure
f4630273ad chore: fix linters issue
(cherry picked from commit 11c7a35eae)
2026-06-12 12:29:08 +00:00
rohitwaghchaure
a65629da1a chore: fix conflicts
(cherry picked from commit d04965b6b2)
2026-06-12 12:29:08 +00:00
Rohit Waghchaure
808ca06801 fix: permissions in workstation file
(cherry picked from commit cf127e8900)

# Conflicts:
#	erpnext/manufacturing/doctype/workstation/workstation.py
(cherry picked from commit 0fea93388d)

# Conflicts:
#	erpnext/manufacturing/doctype/workstation/workstation.py
2026-06-12 12:29:08 +00:00
Mihir Kandoi
74da3b8775 Merge pull request #55839 from frappe/mergify/bp/version-15-hotfix/pr-55838
fix(stock): make uom mandatory in item uom table (backport #55838)
2026-06-12 07:34:20 +05:30
pandiyan
c3a3eb3df3 fix(stock): make uom mandatory in item uom table
(cherry picked from commit a0177fdbe8)
2026-06-12 07:17:08 +05:30
Mihir Kandoi
d4baf0aeba Merge pull request #55833 from frappe/mergify/bp/version-15-hotfix/pr-55820
fix: show user disable audit log (backport #55820)
2026-06-11 21:50:07 +05:30
Mihir Kandoi
e40999c879 fix: sync employee user status after save 2026-06-11 20:58:35 +05:30
pandiyan
559585fb7b fix: show user disable audit log
(cherry picked from commit 73d1852773)
2026-06-11 15:01:32 +00:00
Mihir Kandoi
ee3f502538 Merge pull request #55814 from frappe/mergify/bp/version-15-hotfix/pr-55813
fix(bom): fetch routing operations when Routing is selected (backport #55813)
2026-06-11 20:30:35 +05:30
Mihir Kandoi
f37727d399 chore: resolve conflicts 2026-06-11 20:28:55 +05:30
Mihir Kandoi
b8d507e496 chore: resolve conflicts 2026-06-11 20:28:31 +05:30
ruthra kumar
11359b0ac2 Merge pull request #55819 from frappe/mergify/bp/version-15-hotfix/pr-55696
fix: apply user permissions to receivable/payable reports (backport #55696)
2026-06-11 16:59:48 +05:30
Kaushal Shriwas
7639a3360e test: clean up receivable entries to avoid cross-report leakage 2026-06-11 16:43:05 +05:30
mergify[bot]
f1fc9e3261 fix: added doctype filter validation for sales person wise transaction summary report (backport #55812) (#55817)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
fix: added doctype filter validation for sales person wise transaction summary report (#55812)
2026-06-11 07:08:59 +00:00
Kaushal Shriwas
b9a694bb37 test: cover user permission scoping in receivable report
(cherry picked from commit 4200d17c9b)
2026-06-11 06:58:06 +00:00
Kaushal Shriwas
c03a66a1bf fix: apply user permissions to receivable/payable reports
(cherry picked from commit b05abbc53b)
2026-06-11 06:58:06 +00:00
Mohammad Umair Sayed
02e38e80a7 fix(bom): fetch routing operations when Routing is selected (#55813)
fix(bom): fetch routing operations when routing is selected

frm.doc.operations is always an array in Frappe, so !frm.doc.operations
was always false (empty array [] is truthy in JS), causing get_routing()
to never fire when a Routing is selected on a BOM with no existing
operations.

Changed the guard to !frm.doc.operations.length so the fetch triggers
correctly when the operations table is empty.

Also wired the same fetch into the with_operations handler so that
enabling the checkbox after a Routing is already set will populate
operations without requiring the user to re-select the Routing.

Co-authored-by: Umair Sayed <umairsayed@Umairs-MacBook-Air-2.local>
(cherry picked from commit 9249fa89aa)

# Conflicts:
#	erpnext/manufacturing/doctype/bom/bom.js
2026-06-11 06:25:34 +00:00
Nabin Hait
2905669af4 Merge pull request #55794 from frappe/mergify/bp/version-15-hotfix/pr-55789
fix: prefetch batchwise valuations before streaming SLEs in stock ageing (backport #55789)
2026-06-10 12:14:37 +05:30
Mihir Kandoi
c6176500d2 fix: prefetch batchwise valuations before streaming SLEs in stock ageing
Stock Ageing iterates stock ledger entries through an unbuffered
(streaming) cursor. _get_batchwise_valuation() lazily queried
Batch.use_batchwise_valuation from inside that loop whenever a row
carried the legacy batch_no field, and the nested query invalidated
the active streaming result set — crashing the report (or silently
dropping the remaining rows, depending on the driver version).

Resolve the valuation flags in a single query before entering the
unbuffered cursor block; the lazy lookup now only serves callers that
pass stock ledger entries in directly, where no streaming is active.

Fixes https://github.com/frappe/erpnext/issues/55786

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 060a5c4eeb)
2026-06-10 06:07:48 +00:00
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
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
ruthra kumar
ce94f4fd11 refactor(test): remove flaky test in process pcv 2026-06-01 19:15:50 +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
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
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
117 changed files with 2788 additions and 1979 deletions

10
.greptile/config.json Normal file
View File

@@ -0,0 +1,10 @@
{
"disabledLabels": [
"conflicts"
],
"context": {
"repos": [
"frappe/frappe"
]
}
}

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.109.2"
__version__ = "15.108.3"
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

@@ -90,7 +90,14 @@ class BankClearance(Document):
@frappe.whitelist()
def update_clearance_date(self):
clearance_date_updated = False
payment_docs = []
for d in self.get("payment_entries"):
if d.payment_document not in payment_docs:
payment_docs.append(d.payment_document)
for doctype in payment_docs:
frappe.has_permission(doctype, "write", throw=True)
for d in self.get("payment_entries"):
if d.clearance_date:
if not d.payment_document:

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

@@ -154,12 +154,13 @@ class RepostAccountingLedger(Document):
@frappe.whitelist()
def start_repost(account_repost_doc=str) -> None:
def start_repost(account_repost_doc: str | None = None) -> None:
from erpnext.accounts.general_ledger import make_reverse_gl_entries
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting

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
@@ -492,7 +511,8 @@ def get_party_advance_account(party_type, party, company):
@frappe.whitelist()
def get_party_bank_account(party_type, party):
def get_party_bank_account(party_type: str, party: str):
frappe.has_permission("Bank Account", "read", throw=True)
return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})

View File

@@ -922,8 +922,28 @@ class ReceivablePayableReport:
if self.filters.project:
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
self.add_user_permission_filters()
self.add_accounting_dimensions_filters()
def add_user_permission_filters(self):
# Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.permissions import get_allowed_docs_for_doctype
user_permissions = get_user_permissions()
if not user_permissions:
return
for party_type in self.party_type:
if party_type not in user_permissions:
continue
allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type)
self.qb_selection_filter.append(
(self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""])
)
def get_cost_center_conditions(self):
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))

View File

@@ -1253,3 +1253,53 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
def test_accounts_receivable_respects_user_permissions(self):
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
# must be applied explicitly. The report should only show permitted customers.
# Running the report writes an access log that commits, so these invoices survive
# tearDown's rollback. Delete and commit them so they don't leak into other tests.
def remove_committed_entries():
self.clear_old_entries()
frappe.db.commit() # nosemgrep
self.addCleanup(remove_committed_entries)
original_customer = self.customer
second_customer = "_Test AR Perm Customer"
# create_customer overrides self.customer, so build the restricted invoice first
self.create_customer(customer_name=second_customer)
self.create_sales_invoice(no_payment_schedule=True)
self.customer = original_customer
allowed_invoice = self.create_sales_invoice(no_payment_schedule=True)
test_user = "test_ar_user_permission@example.com"
if not frappe.db.exists("User", test_user):
user = frappe.new_doc("User")
user.email = test_user
user.first_name = "AR Perm"
user.append("roles", {"role": "Accounts User"})
user.save()
frappe.permissions.add_user_permission("Customer", original_customer, test_user)
filters = {
"company": self.company,
"party_type": "Customer",
"report_date": today(),
"range": "30, 60, 90, 120",
}
frappe.set_user(test_user)
try:
report = execute(filters)
finally:
frappe.set_user("Administrator")
parties = {row.party for row in report[1]}
self.assertIn(original_customer, parties)
self.assertNotIn(second_customer, parties)
self.assertEqual(allowed_invoice.customer, original_customer)

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

@@ -533,6 +533,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
target_doc.so_detail = source_doc.so_detail
target_doc.expense_account = source_doc.expense_account
target_doc.dn_detail = source_doc.name
target_doc.cost_center = source_doc.cost_center
if default_warehouse_for_sales_return:
target_doc.warehouse = default_warehouse_for_sales_return
elif doctype == "Sales Invoice" or doctype == "POS Invoice":

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

@@ -14,6 +14,7 @@
"opportunity_section",
"close_opportunity_after_days",
"column_break_9",
"enable_opportunity_creation_from_contact_us",
"quotation_section",
"default_valid_till",
"section_break_13",
@@ -98,13 +99,19 @@
"fieldname": "update_timestamp_on_new_communication",
"fieldtype": "Check",
"label": "Update timestamp on new communication"
},
{
"default": "0",
"fieldname": "enable_opportunity_creation_from_contact_us",
"fieldtype": "Check",
"label": "Enable Opportunity Creation from Contact Us"
}
],
"icon": "fa fa-cog",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-01-16 16:12:14.889455",
"modified": "2026-06-11 23:09:49.750381",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",
@@ -144,4 +151,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -2,6 +2,7 @@
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
@@ -20,8 +21,20 @@ class CRMSettings(Document):
carry_forward_communication_and_comments: DF.Check
close_opportunity_after_days: DF.Int
default_valid_till: DF.Data | None
enable_opportunity_creation_from_contact_us: DF.Check
update_timestamp_on_new_communication: DF.Check
# end: auto-generated types
def validate(self):
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
self.validate_enable_opportunity_creation_from_contact_us()
def validate_enable_opportunity_creation_from_contact_us(self):
contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
if self.enable_opportunity_creation_from_contact_us and contact_disabled:
frappe.throw(
_(
"Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
)
)

View File

@@ -9,7 +9,7 @@ from frappe.contacts.address_and_contact import (
)
from frappe.email.inbox import link_communication_to_document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
from frappe.utils import comma_and, get_link_to_form, validate_email_address
from erpnext.accounts.party import set_taxes
from erpnext.controllers.selling_controller import SellingController
@@ -171,9 +171,6 @@ class Lead(SellingController, CRMNote):
if self.email_id == self.lead_owner:
frappe.throw(_("Lead Owner cannot be same as the Lead Email Address"))
if self.is_new() or not self.image:
self.image = has_gravatar(self.email_id)
def link_to_contact(self):
# update contact links
if self.contact_doc:
@@ -471,7 +468,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None):
@frappe.whitelist()
def make_lead_from_communication(communication, ignore_communication_links=False):
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
"""raise a issue from email"""
doc = frappe.get_doc("Communication", communication)
@@ -490,7 +487,6 @@ def make_lead_from_communication(communication, ignore_communication_links=False
}
)
lead.flags.ignore_mandatory = True
lead.flags.ignore_permissions = True
lead.insert()
lead_name = lead.name

View File

@@ -522,7 +522,9 @@ def auto_close_opportunity():
@frappe.whitelist()
def make_opportunity_from_communication(communication, company, ignore_communication_links=False):
def make_opportunity_from_communication(
communication: str, company: str, ignore_communication_links: bool = False
):
from erpnext.crm.doctype.lead.lead import make_lead_from_communication
doc = frappe.get_doc("Communication", communication)
@@ -540,7 +542,7 @@ def make_opportunity_from_communication(communication, company, ignore_communica
"opportunity_from": opportunity_from,
"party_name": lead,
}
).insert(ignore_permissions=True)
).insert()
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)

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

@@ -5,6 +5,11 @@ from frappe.utils import cstr, now, today
from pypika import functions
def disable_opportunity_creation_on_contact_us_disabled(doc, method):
if doc.is_disabled:
frappe.db.set_single_value("CRM Settings", "enable_opportunity_creation_from_contact_us", 0)
def update_lead_phone_numbers(contact, method):
if contact.phone_nos:
contact_lead = contact.get_link_for("Lead")

View File

@@ -355,6 +355,9 @@ doc_events = {
"Event": {
"after_insert": "erpnext.crm.utils.link_events_with_prospect",
},
"Contact Us Settings": {
"on_update": "erpnext.crm.utils.disable_opportunity_creation_on_contact_us_disabled",
},
"Sales Invoice": {
"on_submit": [
"erpnext.regional.create_transaction_log",

View File

@@ -75,6 +75,9 @@ frappe.ui.form.on("BOM", {
with_operations: function (frm) {
frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0);
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
frm.trigger("routing");
}
},
fg_based_operating_cost: function (frm) {
@@ -438,7 +441,7 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
if (frm.doc.routing) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

@@ -152,6 +152,7 @@ class BOMCreator(Document):
@frappe.whitelist()
def add_boms(self):
self.check_permission("submit")
self.submit()
def set_rate_for_items(self):
@@ -209,10 +210,14 @@ class BOMCreator(Document):
frappe.throw(_("Please set {0} in BOM Creator {1}").format(_(label), self.name))
def on_submit(self):
self.enqueue_create_boms()
self.enqueue_bom_creation()
@frappe.whitelist()
def enqueue_create_boms(self):
self.check_permission("submit")
self.enqueue_bom_creation()
def enqueue_bom_creation(self):
frappe.enqueue(
self.create_boms,
queue="short",
@@ -281,6 +286,21 @@ class BOMCreator(Document):
frappe.msgprint(_("BOMs creation failed"))
@frappe.whitelist()
def edit_qty(self, docname: str, qty: float):
if not frappe.db.exists("BOM Creator Item", {"name": docname, "parent": self.name}):
frappe.throw(_("BOM Creator Item {0} does not exist").format(docname))
for row in self.items:
if row.name == docname:
row.qty = flt(qty)
break
self.set_rate_for_items()
self.save()
return self
def create_bom(self, row, production_item_wise_rm):
bom_creator_item = row.name if row.name != self.name else ""
if frappe.db.exists(
@@ -336,18 +356,157 @@ class BOMCreator(Document):
production_item_wise_rm[(row.item_code, row.name)].bom_no = bom.name
@frappe.whitelist()
def get_default_bom(self, item_code) -> str:
def get_default_bom(self, item_code: str) -> str:
self.check_permission("read")
return frappe.get_cached_value("Item", item_code, "default_bom")
@frappe.whitelist()
def add_item(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
item_info = get_item_details(kwargs.item_code)
parent_row_no = ""
if kwargs.fg_reference_id and self.name != kwargs.fg_reference_id:
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
kwargs.update(
{
"uom": item_info.stock_uom,
"stock_uom": item_info.stock_uom,
"conversion_factor": 1,
}
)
if parent_row_no:
kwargs.update({"parent_row_no": parent_row_no})
for key in BOM_ITEM_FIELDS:
if key not in kwargs:
kwargs[key] = ""
self.append("items", kwargs)
self.save()
return self
@frappe.whitelist()
def add_sub_assembly(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
bom_item = frappe.parse_json(kwargs.bom_item)
name = kwargs.fg_reference_id
parent_row_no = ""
if not kwargs.convert_to_sub_assembly:
item_info = get_item_details(bom_item.item_code)
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
item_row = self.append(
"items",
{
"item_code": bom_item.item_code,
"qty": bom_item.qty,
"uom": item_info.stock_uom,
"fg_item": kwargs.fg_item,
"conversion_factor": 1,
"parent_row_no": parent_row_no,
"fg_reference_id": name,
"stock_qty": bom_item.qty,
"do_not_explode": 1,
"is_expandable": 1,
"stock_uom": item_info.stock_uom,
"allow_alternative_item": kwargs.allow_alternative_item,
},
)
parent_row_no = item_row.idx
name = ""
else:
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
for row in bom_item.get("items"):
row = frappe._dict(row)
item_info = get_item_details(row.item_code)
self.append(
"items",
{
"item_code": row.item_code,
"qty": row.qty,
"fg_item": bom_item.item_code,
"uom": item_info.stock_uom,
"fg_reference_id": name,
"parent_row_no": parent_row_no,
"conversion_factor": 1,
"do_not_explode": 1,
"stock_qty": row.qty,
"stock_uom": item_info.stock_uom,
},
)
self.save()
return self
@frappe.whitelist()
def delete_node(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
updated = False
if kwargs.docname:
row = next((row for row in self.items if row.name == kwargs.docname), None)
if not row:
frappe.throw(_("BOM Creator Item with name {0} does not exist").format(kwargs.docname))
row.delete()
self.remove(row)
updated = True
items = get_children(parent=kwargs.fg_item, parent_id=self.name)
if items:
for item in items:
updated = True
child_row = next((row for row in self.items if row.name == item.name), None)
if child_row:
child_row.delete()
self.remove(child_row)
if item.expandable:
self.delete_node(fg_item=item.value)
if updated:
self.set_rate_for_items()
self.save()
return self
return frappe._dict()
@frappe.whitelist()
def get_children(doctype=None, parent=None, **kwargs):
def get_children(doctype: str | None = None, parent: str | None = None, **kwargs):
# by default get_children takes first parameter as doctype, so added in the function
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
frappe.has_permission("BOM Creator", "read", doc=kwargs.parent_id, throw=True)
fields = [
"item_code as value",
"item_name as title",
@@ -373,102 +532,6 @@ def get_children(doctype=None, parent=None, **kwargs):
return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx")
@frappe.whitelist()
def add_item(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
item_info = get_item_details(kwargs.item_code)
parent_row_no = ""
if kwargs.fg_reference_id and doc.name != kwargs.fg_reference_id:
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
kwargs.update(
{
"uom": item_info.stock_uom,
"stock_uom": item_info.stock_uom,
"conversion_factor": 1,
}
)
if parent_row_no:
kwargs.update({"parent_row_no": parent_row_no})
doc.append("items", kwargs)
doc.save()
return doc
@frappe.whitelist()
def add_sub_assembly(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
bom_item = frappe.parse_json(kwargs.bom_item)
name = kwargs.fg_reference_id
parent_row_no = ""
if not kwargs.convert_to_sub_assembly:
item_info = get_item_details(bom_item.item_code)
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
item_row = doc.append(
"items",
{
"item_code": bom_item.item_code,
"qty": bom_item.qty,
"uom": item_info.stock_uom,
"fg_item": kwargs.fg_item,
"conversion_factor": 1,
"parent_row_no": parent_row_no,
"fg_reference_id": name,
"stock_qty": bom_item.qty,
"do_not_explode": 1,
"is_expandable": 1,
"stock_uom": item_info.stock_uom,
"allow_alternative_item": kwargs.allow_alternative_item,
},
)
parent_row_no = item_row.idx
name = ""
else:
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
for row in bom_item.get("items"):
row = frappe._dict(row)
item_info = get_item_details(row.item_code)
doc.append(
"items",
{
"item_code": row.item_code,
"qty": row.qty,
"fg_item": bom_item.item_code,
"uom": item_info.stock_uom,
"fg_reference_id": name,
"parent_row_no": parent_row_no,
"conversion_factor": 1,
"do_not_explode": 1,
"stock_qty": row.qty,
"stock_uom": item_info.stock_uom,
},
)
doc.save()
return doc
def get_item_details(item_code):
return frappe.get_cached_value(
"Item", item_code, ["item_name", "description", "image", "stock_uom", "default_bom"], as_dict=1
@@ -486,37 +549,3 @@ def get_parent_row_no(doc, name):
frappe.msgprint(_("Parent Row No not found for {0}").format(name), alert=True)
return None
@frappe.whitelist()
def delete_node(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
items = get_children(parent=kwargs.fg_item, parent_id=kwargs.parent)
if kwargs.docname:
frappe.delete_doc("BOM Creator Item", kwargs.docname)
for item in items:
frappe.delete_doc("BOM Creator Item", item.name)
if item.expandable:
delete_node(fg_item=item.value, parent=item.parent_id)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
doc.set_rate_for_items()
doc.save()
return doc
@frappe.whitelist()
def edit_qty(doctype, docname, qty, parent):
frappe.db.set_value(doctype, docname, "qty", qty)
doc = frappe.get_doc("BOM Creator", parent)
doc.set_rate_for_items()
doc.save()
return doc

View File

@@ -6,10 +6,6 @@ import random
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.manufacturing.doctype.bom_creator.bom_creator import (
add_item,
add_sub_assembly,
)
from erpnext.stock.doctype.item.test_item import make_item
@@ -38,8 +34,7 @@ class TestBOMCreator(FrappeTestCase):
conversion_rate=1,
)
add_sub_assembly(
parent=doc.name,
doc.add_sub_assembly(
fg_item=final_product,
fg_reference_id=doc.name,
bom_item={
@@ -91,8 +86,7 @@ class TestBOMCreator(FrappeTestCase):
conversion_rate=1,
)
add_item(
parent=doc.name,
doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -133,8 +127,7 @@ class TestBOMCreator(FrappeTestCase):
conversion_rate=1,
)
add_item(
parent=doc.name,
doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -144,9 +137,8 @@ class TestBOMCreator(FrappeTestCase):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
add_sub_assembly(
doc.add_sub_assembly(
convert_to_sub_assembly=1,
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
@@ -199,8 +191,7 @@ class TestBOMCreator(FrappeTestCase):
conversion_rate=1,
)
add_item(
parent=doc.name,
doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -210,9 +201,8 @@ class TestBOMCreator(FrappeTestCase):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
add_sub_assembly(
doc.add_sub_assembly(
convert_to_sub_assembly=1,
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={

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

@@ -19,6 +19,7 @@ from frappe.utils import (
time_diff_in_seconds,
to_timedelta,
)
from frappe.utils.data import DateTimeLikeObject
from erpnext.support.doctype.issue.issue import get_holidays
@@ -65,7 +66,7 @@ class Workstation(Document):
# end: auto-generated types
def before_save(self):
self.set_data_based_on_workstation_type()
self._set_data_based_on_workstation_type()
self.set_hour_rate()
self.set_total_working_hours()
@@ -92,6 +93,10 @@ class Workstation(Document):
@frappe.whitelist()
def set_data_based_on_workstation_type(self):
self.check_permission("write")
self._set_data_based_on_workstation_type()
def _set_data_based_on_workstation_type(self):
if self.workstation_type:
fields = [
"hour_rate_labour",
@@ -166,23 +171,27 @@ class Workstation(Document):
return schedule_date
@frappe.whitelist()
def start_job(self, job_card, from_time, employee):
def start_job(self, job_card: str, from_time: DateTimeLikeObject, employee: str):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("write")
doc.append("time_logs", {"from_time": from_time, "employee": employee})
doc.save(ignore_permissions=True)
doc.save()
return doc
@frappe.whitelist()
def complete_job(self, job_card, qty, to_time):
def complete_job(self, job_card: str, qty: float, to_time: DateTimeLikeObject):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("submit")
for row in doc.time_logs:
if not row.to_time:
row.to_time = to_time
row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60
row.completed_qty = qty
doc.save(ignore_permissions=True)
doc.save()
doc.submit()
return doc
@@ -364,6 +373,8 @@ def check_workstation_for_holiday(workstation, from_datetime, to_datetime):
@frappe.whitelist()
def get_workstations(**kwargs):
frappe.has_permission("Workstation", "read", throw=True)
kwargs = frappe._dict(kwargs)
_workstation = frappe.qb.DocType("Workstation")

View File

@@ -96,8 +96,8 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
return `
<tr>
<td>${frappe.meta.get_label(doctype, fieldname)}</td>
<td>${value1}</td>
<td>${value2}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
</tr>
`;
})
@@ -138,13 +138,17 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
.map((change, i) => {
let [fieldname, value1, value2] = change;
let th =
i === 0 ? `<th rowspan="${values_changed.length}">${item_code}</th>` : "";
i === 0
? `<th rowspan="${values_changed.length}">${frappe.utils.escape_html(
cstr(item_code)
)}</th>`
: "";
return `
<tr>
${th}
<td>${frappe.meta.get_label(child_doctype, fieldname)}</td>
<td>${value1}</td>
<td>${value2}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
</tr>
`;
})
@@ -177,7 +181,9 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
let html = rows
.map((row) => {
let [, doc] = row;
let cells = fields.map((df) => `<td>${doc[df.fieldname]}</td>`).join("");
let cells = fields
.map((df) => `<td>${frappe.utils.escape_html(cstr(doc[df.fieldname]))}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");

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

@@ -219,14 +219,10 @@ class BOMConfigurator {
},
],
(data) => {
if (!node.data.parent_id) {
node.data.parent_id = this.frm.doc.name;
}
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_item",
method: "add_item",
doc: this.frm.doc,
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
item_code: data.item_code,
fg_reference_id: node.data.name || this.frm.doc.name,
@@ -255,14 +251,10 @@ class BOMConfigurator {
dialog.set_primary_action(__("Add"), () => {
let bom_item = dialog.get_values();
if (!node.data?.parent_id) {
node.data.parent_id = this.frm.doc.name;
}
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
method: "add_sub_assembly",
doc: this.frm.doc,
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
fg_reference_id: node.data.name || this.frm.doc.name,
bom_item: bom_item,
@@ -357,9 +349,9 @@ class BOMConfigurator {
let bom_item = dialog.get_values();
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
method: "add_sub_assembly",
doc: this.frm.doc,
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
bom_item: bom_item,
fg_reference_id: node.data.name || this.frm.doc.name,
@@ -389,11 +381,10 @@ class BOMConfigurator {
delete_node(node, view) {
frappe.confirm(__("Are you sure you want to delete this Item?"), () => {
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.delete_node",
method: "delete_node",
doc: this.frm.doc,
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
doctype: node.data.doctype,
docname: node.data.name,
},
callback: (r) => {
@@ -408,16 +399,14 @@ class BOMConfigurator {
frappe.prompt(
[{ label: __("Qty"), fieldname: "qty", default: qty, fieldtype: "Float", reqd: 1 }],
(data) => {
let doctype = node.data.doctype || this.frm.doc.doctype;
let docname = node.data.name || this.frm.doc.name;
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.edit_qty",
method: "edit_qty",
doc: this.frm.doc,
args: {
doctype: doctype,
docname: docname,
qty: data.qty,
parent: node.data.parent_id ? node.data.parent_id : this.frm.doc.name,
},
callback: (r) => {
node.data.qty = data.qty;

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

@@ -13,6 +13,8 @@ def execute(filters=None):
if not filters:
filters = {}
validate_filters(filters)
columns = get_columns(filters)
entries = get_entries(filters)
item_details = get_item_details()
@@ -49,10 +51,17 @@ def execute(filters=None):
return columns, data
def get_columns(filters):
def validate_filters(filters):
ALLOWED_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note"]
if not filters.get("doc_type"):
msgprint(_("Please select the document type first"), raise_exception=1)
if filters.get("doc_type") not in ALLOWED_DOCTYPES:
frappe.throw(_("{0}, {1} or {2} are the only allowed options.").format(*ALLOWED_DOCTYPES))
def get_columns(filters):
columns = [
{
"label": _(filters["doc_type"]),

View File

@@ -64,15 +64,11 @@ class Employee(NestedSet):
)
def validate_user_details(self):
if self.user_id:
data = frappe.db.get_value("User", self.user_id, ["enabled"], as_dict=1)
if not self.user_id:
return
if not data:
self.user_id = None
return
self.validate_for_enabled_user_id(data.get("enabled", 0))
self.validate_duplicate_user_id()
self.validate_for_enabled_user_id()
self.validate_duplicate_user_id()
def update_nsm_model(self):
frappe.utils.nestedset.update_nsm(self)
@@ -83,6 +79,7 @@ class Employee(NestedSet):
if self.user_id:
self.update_user()
self.update_user_permissions()
self.update_user_status()
self.reset_employee_emails_cache()
def update_user_permissions(self):
@@ -184,12 +181,20 @@ class Employee(NestedSet):
if not self.relieving_date:
throw(_("Please enter relieving date."))
def validate_for_enabled_user_id(self, enabled):
if enabled is None:
def validate_for_enabled_user_id(self):
if not frappe.db.exists("User", self.user_id):
frappe.throw(_("User {0} does not exist").format(self.user_id))
def update_user_status(self):
if not self.user_id:
return
user = frappe.get_doc("User", self.user_id)
enabled = user.enabled
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
frappe.db.set_value("User", self.user_id, "enabled", not enabled)
user.enabled = not enabled
# Keep linked User status in sync from the Employee lifecycle and record the audit log.
user.save(ignore_permissions=True)
def validate_duplicate_user_id(self):
Employee = frappe.qb.DocType("Employee")

View File

@@ -209,6 +209,8 @@ class TransactionDeletionRecord(Document):
@frappe.whitelist()
def start_deletion_tasks(self):
self.check_permission("write")
# This method is the entry point for the chain of events that follow
self.db_set("status", "Running")
self.enqueue_task(task="Delete Bins")

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

@@ -335,7 +335,9 @@ def get_default_address(out, name):
@frappe.whitelist()
def get_contact_display(contact):
def get_contact_display(contact: str):
frappe.has_permission("Contact", "read", doc=contact, throw=True)
contact_info = frappe.db.get_value(
"Contact", contact, ["first_name", "last_name", "phone", "mobile_no"], as_dict=1
)
@@ -436,7 +438,9 @@ def get_attachments(delivery_stop):
@frappe.whitelist()
def get_driver_email(driver):
def get_driver_email(driver: str):
frappe.has_permission("Driver", "read", doc=driver, throw=True)
employee = frappe.db.get_value("Driver", driver, "employee")
email = frappe.db.get_value("Employee", employee, "prefered_email")
return {"email": email}

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

@@ -123,7 +123,9 @@ def get_contact_name(ref_doctype, docname):
@frappe.whitelist()
def get_company_contact(user):
def get_company_contact(user: str):
frappe.has_permission("User", "read", throw=True)
contact = frappe.db.get_value(
"User",
user,

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)

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