Compare commits

..

1200 Commits

Author SHA1 Message Date
Nabin Hait
a797c31b57 test: reuse BootStrapTestData master data in Cash Flow report tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:09:00 +05:30
Mihir Kandoi
a972ef313a Merge pull request #56127 from harisansari008/fix/qip-allow-rename-develop
fix: allow rename for Quality Inspection Parameter
2026-06-26 13:43:40 +05:30
Mihir Kandoi
05e44ca63a Merge pull request #56546 from mihir-kandoi/pg-rawsql-to-orm
refactor: convert convertible raw frappe.db.sql to ORM
2026-06-26 12:21:25 +05:30
Mihir Kandoi
0583349ae4 test: convert aggregate/pluck/positional raw SQL to ORM
A deeper re-audit (with an adversarial skeptic) of the queries left raw
in the prior commit found more that have exact ORM equivalents:

- scalar SUM/MAX  -> frappe.qb + Sum/Max .run()[0][0]
- SUM ... GROUP BY -> frappe.qb .groupby().select(Sum().as_()) run(as_dict)
- name IN (values) -> get_all(filters={'f': ['in', ...]})
- sql_list(select col) -> get_all(pluck='col')
- bulk UPDATE ... = NULL/value -> frappe.db.set_value(filters, field, val)
- positional as_list reads -> get_all(..., as_list=True) (+ sorted())

Note: get_value(dt, filters, 'sum(x)') and get_all(fields=['sum(x)'])
are rejected by frappe ('SQL functions are not allowed as strings'), so
aggregates go through frappe.qb. get_all(as_list=True) returns a tuple
(not a list), so consumers that mutate use sorted().

All affected test modules pass on MariaDB.
2026-06-26 11:34:31 +05:30
Mihir Kandoi
0776f7f7fa test: convert trivially-equivalent raw SQL to ORM helpers
Convert test-only raw frappe.db.sql calls that have an exact ORM
equivalent: full-table/filtered deletes -> frappe.db.delete, count ->
frappe.db.count, row-existence assertions -> frappe.db.exists,
single-row scalar fetches -> frappe.db.get_value, and simple
equality/range-filter selects -> frappe.get_all. No behaviour change.

Raw SQL that genuinely needs it is left as-is (dynamic identifiers,
aggregates/group-by, positional as_list consumers, DB-catalog
introspection).
2026-06-26 10:44:17 +05:30
Mihir Kandoi
0e54e532ff refactor(accounts): use frappe.get_all for trial balance account fetch
The Account metadata fetch in the DuckDB trial-balance path is a plain
static SELECT (fixed columns, single company filter, order by lft).
Convert it to frappe.get_all. Verified on Postgres: identical 98 rows,
same order and same dict payload as the raw query.
2026-06-26 10:44:05 +05:30
Mihir Kandoi
ea9bf932d8 Merge pull request #56510 from frappe/revert-56497-serial-batch-bundle-print-none-fix
Revert "fix: handle missing serial and batch bundle in print format"
2026-06-26 10:18:43 +05:30
Mihir Kandoi
993a011005 Revert "fix: handle missing serial and batch bundle in print format" 2026-06-26 10:07:23 +05:30
Nabin Hait
507bc0930e Merge pull request #56412 from frappe/chore/fixed-asset-register-test-coverage
test: Fixed Asset Register report coverage
2026-06-25 22:13:39 +05:30
MochaMind
c3d2ebd734 fix: sync translations from crowdin (#56396) 2026-06-25 16:35:56 +02:00
Shllokkk
142d80d7de Merge pull request #56497 from Shllokkk/serial-batch-bundle-print-none-fix
fix: handle missing serial and batch bundle in print format
2026-06-25 19:22:23 +05:30
Mihir Kandoi
c8f86099e2 Merge pull request #56479 from mihir-kandoi/messages/crm
chore: rewrite user-facing messages in crm module
2026-06-25 18:47:25 +05:30
Mihir Kandoi
ab1e949752 Merge pull request #56482 from mihir-kandoi/messages/erpnext_integrations
chore: rewrite user-facing messages in erpnext_integrations module
2026-06-25 18:43:52 +05:30
Mihir Kandoi
8ad90338a3 Merge pull request #56469 from mihir-kandoi/messages/controllers
chore: rewrite user-facing messages in controllers module
2026-06-25 18:38:23 +05:30
Mihir Kandoi
d2ff0913df Merge pull request #56476 from mihir-kandoi/messages/projects
chore: rewrite user-facing messages in projects module
2026-06-25 18:35:00 +05:30
Mihir Kandoi
05dd44246f Merge pull request #56475 from mihir-kandoi/messages/subcontracting
chore: rewrite user-facing messages in subcontracting module
2026-06-25 18:33:55 +05:30
Mihir Kandoi
6e22f4b063 Merge pull request #56498 from mihir-kandoi/messages/exchange-rate-server-grammar
chore: fix grammar in Exchange Rate Revaluation validation message
2026-06-25 18:28:11 +05:30
Mihir Kandoi
d737c39131 Merge pull request #56473 from mihir-kandoi/messages/selling
chore: rewrite user-facing messages in selling module
2026-06-25 18:24:13 +05:30
Nabin Hait
38385432f6 test: assert group-by totals on delta to avoid shared-category leak
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:22:58 +05:30
Nabin Hait
78fd06048f style: apply ruff formatting
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:21:08 +05:30
Mihir Kandoi
1b68445313 Merge pull request #56480 from mihir-kandoi/messages/utilities
chore: rewrite user-facing messages in utilities module
2026-06-25 18:17:50 +05:30
Nabin Hait
3a3f56350f Merge remote-tracking branch 'origin/develop' into chore/fixed-asset-register-test-coverage
# Conflicts:
#	erpnext/assets/report/fixed_asset_register/test_fixed_asset_register.py
2026-06-25 18:10:53 +05:30
Mihir Kandoi
6d035274d7 Merge pull request #56478 from mihir-kandoi/messages/maintenance
chore: rewrite user-facing messages in maintenance module
2026-06-25 18:06:57 +05:30
Mihir Kandoi
df6b8cdd60 Merge pull request #56481 from mihir-kandoi/messages/support
chore: rewrite user-facing messages in support module
2026-06-25 18:04:34 +05:30
Mihir Kandoi
62f7374942 Merge pull request #56477 from mihir-kandoi/messages/regional
chore: rewrite user-facing messages in regional module
2026-06-25 18:00:07 +05:30
Mihir Kandoi
f571ba749f Merge pull request #56468 from mihir-kandoi/messages/manufacturing
chore: rewrite user-facing messages in manufacturing module
2026-06-25 17:56:28 +05:30
Mihir Kandoi
f151b2abee Merge pull request #56485 from mihir-kandoi/messages-js/public
chore: rewrite user-facing JS messages in public module
2026-06-25 17:56:19 +05:30
Mihir Kandoi
27a7ec82c2 Merge pull request #56471 from mihir-kandoi/messages/setup
chore: rewrite user-facing messages in setup module
2026-06-25 17:54:53 +05:30
Mihir Kandoi
c769bc24c9 Merge pull request #56495 from mihir-kandoi/messages-js/support
chore: rewrite user-facing JS messages in support module
2026-06-25 17:54:41 +05:30
Mihir Kandoi
fa9e775e9d Merge pull request #56494 from mihir-kandoi/messages-js/subcontracting
chore: rewrite user-facing JS messages in subcontracting module
2026-06-25 17:54:25 +05:30
Nabin Hait
fe863d2e7f Merge pull request #56347 from frappe/chore/ar-ap-report-test-coverage
test: AR/AP journal-entry payments and credit notes
2026-06-25 17:49:47 +05:30
Nabin Hait
a97b944bec Merge pull request #56346 from frappe/chore/stock-balance-report-test-coverage
test: Stock Balance include-zero and ageing checkbox filters
2026-06-25 17:49:21 +05:30
Nabin Hait
851439e2d9 Merge pull request #56327 from frappe/chore/cash-flow-report-test-coverage
test: Cash Flow report correctness coverage
2026-06-25 17:48:48 +05:30
Mihir Kandoi
0dc649bae9 Merge pull request #56493 from mihir-kandoi/messages-js/www
chore: rewrite user-facing JS messages in www module
2026-06-25 17:48:27 +05:30
Mihir Kandoi
8de7a25d16 Merge pull request #56496 from mihir-kandoi/messages-js/projects
chore: rewrite user-facing JS messages in projects module
2026-06-25 17:48:08 +05:30
Mihir Kandoi
e86be20f44 Merge pull request #56490 from mihir-kandoi/messages-js/manufacturing
chore: rewrite user-facing JS messages in manufacturing module
2026-06-25 17:47:56 +05:30
Nabin Hait
fa99849e48 Merge pull request #56326 from frappe/chore/gl-financial-statements-test-coverage
test: General Ledger report filter coverage
2026-06-25 17:47:45 +05:30
Mihir Kandoi
21dbd0007b Merge pull request #56492 from mihir-kandoi/messages-js/buying
chore: rewrite user-facing JS messages in buying module
2026-06-25 17:47:36 +05:30
Nabin Hait
8a581d4e4e Merge pull request #56324 from frappe/chore/trial-balance-test-coverage
test: correctness coverage for Trial Balance report
2026-06-25 17:47:14 +05:30
Mihir Kandoi
b077491fc7 Merge pull request #56491 from mihir-kandoi/messages-js/setup
chore: rewrite user-facing JS messages in setup module
2026-06-25 17:47:03 +05:30
Mihir Kandoi
f46e9a0bb5 Merge pull request #56484 from mihir-kandoi/messages-js/accounts
chore: rewrite user-facing JS messages in accounts module
2026-06-25 17:46:49 +05:30
Mihir Kandoi
c100e1c94c Merge pull request #56489 from mihir-kandoi/messages-js/selling
chore: rewrite user-facing JS messages in selling module
2026-06-25 17:46:46 +05:30
Mihir Kandoi
b36eeb7813 Merge pull request #56488 from mihir-kandoi/messages-js/stock
chore: rewrite user-facing JS messages in stock module
2026-06-25 17:46:07 +05:30
Nabin Hait
46917cc36f Merge pull request #56320 from frappe/chore/stock-report-test-coverage
test: correctness coverage for Stock Ledger and Stock Projected Qty reports
2026-06-25 17:45:01 +05:30
Mihir Kandoi
70bd57d3e7 chore: fix grammar in Exchange Rate Revaluation validation message
"to getting entries" -> "to get entries". Matches the client-side message
fixed in #56484 so the same missing Company/Posting Date validation reads
identically on client and server. Part of #53976.
2026-06-25 17:44:57 +05:30
Mihir Kandoi
660fc4191c chore: rewrite user-facing JS messages in Support module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:44:29 +05:30
Nabin Hait
555ce3fc2a Merge pull request #56322 from nabinhait/test-project-percent-complete
test(projects): cover untested project and project-template functions
2026-06-25 17:44:19 +05:30
Mihir Kandoi
8cd420953c chore: rewrite user-facing JS messages in Public module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:44:14 +05:30
Nabin Hait
57cb133aaf Merge pull request #56325 from nabinhait/test-task-timesheet-coverage
test(projects): cover untested task, timesheet, and activity-cost functions
2026-06-25 17:43:16 +05:30
Shllokkk
548d90df4f fix: handle missing serial and batch bundle in print format 2026-06-25 17:42:47 +05:30
Mihir Kandoi
64fef7e108 chore: rewrite user-facing JS messages in Projects module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:38:20 +05:30
Mihir Kandoi
ad27dc4907 chore: rewrite user-facing JS messages in Subcontracting module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:38:12 +05:30
Mihir Kandoi
5438b0dbf1 chore: rewrite user-facing JS messages in Www module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:38:08 +05:30
Mihir Kandoi
41c7f2fd48 chore: rewrite user-facing JS messages in Buying module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:38:03 +05:30
Mihir Kandoi
d451eddac2 chore: rewrite user-facing JS messages in Setup module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:59 +05:30
Mihir Kandoi
51c4fc9dcc chore: rewrite user-facing JS messages in Manufacturing module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:55 +05:30
Mihir Kandoi
c5e911dd07 chore: rewrite user-facing JS messages in Selling module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:51 +05:30
Mihir Kandoi
08664181d4 chore: rewrite user-facing JS messages in Stock module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:46 +05:30
Mihir Kandoi
ac22fd0360 chore: rewrite user-facing JS messages in Accounts module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:37 +05:30
Mihir Kandoi
9ec043ffcb chore: rewrite user-facing messages in Erpnext Integrations module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:33 +05:30
Mihir Kandoi
410a95cad2 chore: rewrite user-facing messages in Support module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:29 +05:30
Mihir Kandoi
10744d1332 chore: rewrite user-facing messages in Utilities module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:24 +05:30
Mihir Kandoi
b161e5aa79 chore: rewrite user-facing messages in Crm module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:20 +05:30
Mihir Kandoi
fa9fb12c8d chore: rewrite user-facing messages in Maintenance module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:16 +05:30
Mihir Kandoi
0e4d1da087 chore: rewrite user-facing messages in Regional module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:12 +05:30
Mihir Kandoi
4efb43d977 chore: rewrite user-facing messages in Projects module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:08 +05:30
Mihir Kandoi
f5bf915104 chore: rewrite user-facing messages in Subcontracting module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:37:03 +05:30
Mihir Kandoi
33562a6a86 chore: rewrite user-facing messages in Selling module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:36:59 +05:30
Mihir Kandoi
4b8b52b908 chore: rewrite user-facing messages in Setup module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:36:55 +05:30
Mihir Kandoi
95b6cf2847 chore: rewrite user-facing messages in Controllers module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:36:50 +05:30
Mihir Kandoi
647fdc4e3d chore: rewrite user-facing messages in Manufacturing module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 17:36:45 +05:30
Nabin Hait
0a462f8d2f test: cover combined depreciation and revaluation in FA register
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:46:02 +05:30
Mihir Kandoi
b0887e03fe Merge pull request #56474 from mihir-kandoi/messages/buying
fix: rewrite user-facing messages in buying module
2026-06-25 16:35:08 +05:30
Nabin Hait
f4413ebda3 test: cover depreciation, sale, revaluation and capitalization in FA register
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:30:22 +05:30
Mihir Kandoi
a975caf8f8 Merge pull request #56470 from mihir-kandoi/messages/assets
fix: rewrite user-facing messages in assets module
2026-06-25 16:26:55 +05:30
Mihir Kandoi
7124e47490 Merge pull request #56464 from mihir-kandoi/messages/accounts
fix: rewrite user-facing messages in Accounts module
2026-06-25 16:20:31 +05:30
Mihir Kandoi
600bf9e249 fix: rewrite user-facing messages in Buying module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 16:15:33 +05:30
Mihir Kandoi
e569e2f98c fix: rewrite user-facing messages in Assets module
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
  extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings

Part of #53976.
2026-06-25 16:11:29 +05:30
Mihir Kandoi
dd600c3a79 fix: rewrite user-facing messages in Accounts module
Conservative cleanup of frappe.throw/msgprint messages per the message
style guide; meaning, severity, and .format() arguments are unchanged:

- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break
  gettext extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms ("doesn't belongs" -> "does not belong",
  "till" -> "until", "Rules exists" -> "Rules exist", exclusive "one of
  X and Y" -> "one of X, Y, or Z")
- drop no-op _() wrapping runtime-built HTML strings

Part of #53976.
2026-06-25 15:37:49 +05:30
Diptanil Saha
9b4c8a8d7f fix(crm): using get_list instead of get_all in get_opportunities (#56463) 2026-06-25 10:00:44 +00:00
Raffael Meyer
7f58c7f0ac ci: bump po review action (#56454) 2026-06-25 09:27:45 +00:00
Smit Vora
cb0689bd1e fix: rewrite item rate calculation (#56315)
Co-authored-by: Harsh Patadia <harsh@Harshs-MacBook-Air.local>
Co-authored-by: Sagar Vora <16315650+sagarvora@users.noreply.github.com>
2026-06-25 14:49:11 +05:30
Mihir Kandoi
4304f5129f Merge pull request #56453 from mihir-kandoi/pg-null-ordering-sentinels
fix(postgres): match MariaDB NULL ordering in the queries where it changes the result
2026-06-25 14:48:41 +05:30
Mihir Kandoi
e8e50edbed fix(postgres): pricing rule priority order diverges on Postgres
_get_pricing_rules orders by priority desc; get_pricing_rules then reads
pricing_rules[0].has_priority. priority is a Select (varchar): unset is '' on
MariaDB but NULL on Postgres, which sorts to the top under DESC and flips the
selection. Order by coalesce(priority, '') desc so the unset value sorts last
('' is the text minimum) on both backends.
2026-06-25 14:39:08 +05:30
Mihir Kandoi
934b5065fc fix(postgres): expiry-based serial selection picks wrong serials on Postgres
get_serial_nos_based_on_filters with based_on='Expiry' orders by amc_expiry_date
asc and limits to qty. MariaDB sorts NULL (no-AMC) serials first; Postgres last,
so a different set of serials is auto-selected. Order by (amc_expiry_date IS NULL)
desc, amc_expiry_date so NULLs sort first on both (MariaDB unchanged).
2026-06-25 14:39:08 +05:30
Mihir Kandoi
5fb16ca20c fix(postgres): get_item_price picks undated price on Postgres
Both Item Price lookups order by valid_from desc and take the first valid row.
MariaDB sorts NULL valid_from last; Postgres first, so the undated base price was
winning over a dated one. Order by (valid_from IS NULL) asc, valid_from desc (the
get_all is converted to qb since its order_by won't take such an expression).
MariaDB output is unchanged.
2026-06-25 14:39:07 +05:30
Mihir Kandoi
b10cf2fb65 fix(postgres): POS item price ignores NULL valid_from ordering on Postgres
get_items orders Item Price by valid_from and picks the first match. MariaDB sorts
NULL valid_from last under DESC (so a dated price wins); Postgres sorts it first,
so the undated base price wins and POS shows the wrong rate. Order by
(valid_from IS NULL) asc, valid_from desc so NULLs sort last on both backends
regardless of any real date value (MariaDB unchanged).
2026-06-25 14:39:05 +05:30
Mihir Kandoi
da8ac36b92 Merge pull request #56410 from mihir-kandoi/mariadb-ci-fanout
ci(mariadb): self-hosted fan-out MariaDB CI (ARC runners, datadir bake)
2026-06-25 13:58:48 +05:30
Mihir Kandoi
f3315ecb34 ci(mariadb): un-wire warm-bench (no measurable gain)
Measured A/B on the self-hosted setup: warm-bench restore 85s vs full
bench init 82s — no gain (slightly slower). bench init is already fast
because the uv/pip caches are mounted warm, so the cache only replaces a
~13s init while adding a ~200MB untar and still running bench build.

Drop BENCH_CACHE_DIR so warm-bench stays inert (the helper functions
remain, matching develop's install.sh).
2026-06-25 13:47:54 +05:30
Mihir Kandoi
61f0a39716 Merge remote-tracking branch 'frappe/develop' into mariadb-ci-fanout
# Conflicts:
#	.github/helper/hydrate.sh
#	.github/helper/install.sh
#	.github/helper/start-db.sh
2026-06-25 13:31:28 +05:30
Mihir Kandoi
368ea75e38 ci: address self-review findings
- Wire up warm-bench: set BENCH_CACHE_DIR on the setup job so the bench-base
  cache actually activates (was inert with no dir set, so every run did a
  full bench init). Lives on the node-local bench-staging hostPath; any
  miss/failure still falls back to a full init.
- run_ci_step: capture the timeout exit code under `set -e` (the previous
  `timeout ...` + `ec=$?` aborted at the timeout line on failure, skipping
  ::endgroup:: and the exit-code return).
- Raise the per-step timeout 600s -> 1800s so a contended reinstall isn't
  killed before the 40-min job timeout.
- Propagate DB through the su re-exec in start-db.sh / hydrate.sh so a
  DB=postgres invocation can't silently fall back to the mariadb branch.
- Simplify the coverage job `if` to the equivalent plain non-PR gate.
2026-06-25 13:24:15 +05:30
ruthra kumar
bd53db61cc Merge pull request #56304 from ruthra-kumar/reports_on_duckdb
feat: faster (synced) financial statements using duckdb
2026-06-25 12:40:08 +05:30
ruthra kumar
963bbc8729 refactor: synced reports should be enabled on sites based on requirements 2026-06-25 12:03:42 +05:30
Mihir Kandoi
beec05ce1c ci: restore postgres durability-off settings in install.sh
The fan-out moved fsync/synchronous_commit/full_page_writes=off into
start-db.sh startup flags, but the Postgres workflow runs a postgres:13.3
service container and calls install.sh directly — it never runs start-db.sh.
So those flags never reached the Postgres CI, regressing it to full durability
on a commit-heavy suite. Re-apply them via ALTER SYSTEM (reloadable) in the
DB == "postgres" path, where the service-container workflow executes. MariaDB
is unaffected (DB != postgres).
2026-06-25 12:03:07 +05:30
Mihir Kandoi
694f46f7f7 ci(mariadb): track the erpnext branch for frappe, drop hardcoded develop
Remove the `|| 'develop'` fallback on FRAPPE_BRANCH so install.sh resolves the
frappe framework branch from the git context (GITHUB_BASE_REF/GITHUB_REF), the
same as the Postgres workflow. This makes the workflow backportable unchanged:
on version-15-hotfix / version-16-hotfix it now clones the matching frappe
branch instead of develop.
2026-06-25 11:54:26 +05:30
Khushi Rawat
ce44d9192d Merge pull request #56432 from iamejaaz/fix-letterhead-no-company
fix(letter-head): guard company lookups when doc has no company field
2026-06-25 11:11:23 +05:30
ruthra kumar
6a93baacf0 feat(profit-and-loss): implement execute_synced_report with full parity to normal report
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:01:32 +05:30
ruthra kumar
bb19540816 feat(balance-sheet): implement execute_synced_report with full parity to normal report
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:01:32 +05:30
ruthra kumar
6b4895bcc9 feat(general-ledger): implement execute_synced_report with full parity to normal report
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:01:32 +05:30
ruthra kumar
f40cd41801 refactor: DB agnostic method names 2026-06-25 11:01:32 +05:30
ruthra kumar
5c536b8ad1 refactor: maintain sync dependency in report master 2026-06-25 11:01:32 +05:30
ruthra kumar
55862f98f4 refactor(trial-balance): execute_duckdb only reads GL Entry from duckdb
Replaces the previous over-engineered stub with 7 short functions.
Account data, Account Closing Balance, and all metadata come from
frappe.db as normal; only tabGL Entry is read from the duckdb_conn.

Reuses get_opening_balance() for Account Closing Balance unchanged,
reuses all downstream compute helpers (calculate_values, prepare_data,
etc.) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:01:32 +05:30
ruthra kumar
b1c8e2cb5c feat(trial-balance): implement execute_duckdb with full parity to normal report
Replaces the placeholder stub with 8 focused functions that mirror the
normal execute() flow using parameterized DuckDB SQL queries: account
fetch, period GL entries, opening balances (with Period Closing Voucher
path), and all filters (cost center, project, finance book, accounting
dimensions). Reuses existing pure-Python processing functions unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:01:32 +05:30
ruthra kumar
adb768505a refactor: reports on duckdb 2026-06-25 11:01:32 +05:30
Mihir Kandoi
dfdfcb8ca1 ci(mariadb): fix stale 'restore DB dump' fan-out comment
The shards start MariaDB on the baked datadir; there is no DB dump restore.
Aligns the overview comment with the corrected Hydrate step (greptile).
2026-06-25 06:28:28 +05:30
Mihir Kandoi
fcfaa8843b Merge pull request #56439 from mihir-kandoi/migrate-request-type-json
fix!: switch ERPNext to JSON request body (use_json_request_body)
2026-06-24 22:37:59 +05:30
Mihir Kandoi
3be80d8e87 fix: include list in get_events filters type hint
The calendar view sends filters as a list of [doctype, field, op, value]
conditions (filter_area.get()); get_filter_conditions_qb accepts dict or list.
2026-06-24 22:01:55 +05:30
Mihir Kandoi
f034bb55d3 fix: correct process_genericode_import filters hint to str | dict | None
import_genericode consumes filters via (filters or {}).items(), so it is a
dict, not a list.
2026-06-24 22:01:55 +05:30
Mihir Kandoi
71b4cc4f12 refactor: drop dead except TypeError in add_bank_accounts
frappe.parse_json never raises TypeError (unlike json.loads on a non-str),
so the try/except guarding the parse is now unreachable.
2026-06-24 20:55:01 +05:30
Mihir Kandoi
6a4afd1733 fix: parse native JSON contacts args in CRM prospect/customer endpoints
create_prospect_against_crm_deal and create_customer read `contacts`
from form_dict (json.loads(doc.contacts) / customer_data.get("contacts")),
which arrives as a native list under JSON body mode. Use frappe.parse_json.
2026-06-24 20:55:01 +05:30
Mihir Kandoi
3fa7ec656b fix: parse native JSON schedules arg in make_payment_request
make_payment_request(**args) is whitelisted and the client passes
`schedules` as a list, so json.loads(args.get("schedules")) raised
TypeError under JSON body mode. Use frappe.parse_json.
2026-06-24 20:55:01 +05:30
Mihir Kandoi
8854f0c153 feat!: send ERPNext requests as native JSON (use_json_request_body)
Opt ERPNext into native application/json request bodies (frappe#40237).
Non-GET requests to erpnext.* endpoints now send args as a JSON body
instead of form-encoded, per-key JSON-stringified values. Safe after the
preceding commits hardened every whitelisted endpoint with frappe.parse_json.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
9955adb2fc refactor: parse native JSON request args in www/book_appointment/index.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
785c34e0ad refactor: parse native JSON request args in utilities/query.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
7e8965c6be refactor: parse native JSON request args in utilities/bulk_transaction.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
7cbebc0545 refactor: parse native JSON request args in support/doctype/issue/issue.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
b3526db643 refactor: parse native JSON request args in stock/utils.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
f707da40ec refactor: parse native JSON request args in stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
cb2679ba2c refactor: parse native JSON request args in stock/get_item_details.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
a6ede74b2d refactor: parse native JSON request args in stock/doctype/warehouse/warehouse.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
5aeb711f69 refactor: parse native JSON request args in stock/doctype/stock_reconciliation/stock_reconciliation.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
9506a9d62a refactor: parse native JSON request args in stock/doctype/stock_entry/stock_entry.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
63a1b7d8e5 refactor: parse native JSON request args in stock/doctype/stock_entry/services/subcontracting.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
f5bf9392a0 refactor: parse native JSON request args in stock/doctype/stock_entry/services/manufacturing.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
ddd57ca12e refactor: parse native JSON request args in stock/doctype/serial_no/serial_no.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
2c7cea2879 refactor: parse native JSON request args in stock/doctype/repost_item_valuation/repost_item_valuation.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
8ca2e99cf2 refactor: parse native JSON request args in stock/doctype/putaway_rule/putaway_rule.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
a11eb741e5 refactor: parse native JSON request args in stock/doctype/purchase_receipt/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:35 +05:30
Mihir Kandoi
487aff80e0 refactor: parse native JSON request args in stock/doctype/pick_list/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
68e92a893a refactor: parse native JSON request args in stock/doctype/packed_item/packed_item.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
ccd115e769 refactor: parse native JSON request args in stock/doctype/material_request/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
5a77df6560 refactor: parse native JSON request args in stock/doctype/delivery_note/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
04a93cabf1 refactor: parse native JSON request args in stock/doctype/batch/batch.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
460b8c5d8d refactor: parse native JSON request args in setup/doctype/terms_and_conditions/terms_and_conditions.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
76705dd736 refactor: parse native JSON request args in setup/doctype/holiday_list/holiday_list.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
78f38970c1 refactor: parse native JSON request args in setup/doctype/department/department.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
8dd05ca056 refactor: parse native JSON request args in selling/page/point_of_sale/point_of_sale.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
f4df5ee0bc refactor: parse native JSON request args in selling/doctype/sales_order/sales_order.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
517f97ff73 refactor: parse native JSON request args in selling/doctype/sales_order/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
bf30b58d02 refactor: parse native JSON request args in selling/doctype/quotation/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
5543577ca7 refactor: parse native JSON request args in selling/doctype/customer/customer.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
7835cbaa56 refactor: parse native JSON request args in regional/report/irs_1099/irs_1099.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
cd8b740cb3 refactor: parse native JSON request args in regional/italy/utils.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:34 +05:30
Mihir Kandoi
cb236dedfc refactor: parse native JSON request args in projects/doctype/timesheet/timesheet.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:33 +05:30
Mihir Kandoi
5629f81809 refactor: parse native JSON request args in projects/doctype/task/task.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:33 +05:30
Mihir Kandoi
e432f8284b refactor: parse native JSON request args in projects/doctype/project/project.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:33 +05:30
Mihir Kandoi
c1ec503858 refactor: parse native JSON request args in manufacturing/doctype/work_order/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:33 +05:30
Mihir Kandoi
e14719b0ad refactor: parse native JSON request args in manufacturing/doctype/production_plan/services/planning_queries.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:33 +05:30
Mihir Kandoi
3946bf5366 refactor: parse native JSON request args in manufacturing/doctype/production_plan/services/material_request.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:33 +05:30
Mihir Kandoi
6b1e18f79e refactor: parse native JSON request args in manufacturing/doctype/job_card/job_card.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:33 +05:30
Mihir Kandoi
5a1abf6138 refactor: parse native JSON request args in manufacturing/doctype/bom_update_tool/bom_update_tool.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:33 +05:30
Mihir Kandoi
5c12bf02d8 refactor: parse native JSON request args in manufacturing/doctype/bom/bom.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
2e75a4b830 refactor: parse native JSON request args in erpnext_integrations/doctype/plaid_settings/plaid_settings.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
afb2616aee refactor: parse native JSON request args in edi/doctype/code_list/code_list_import.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
ffa85a4ed6 refactor: parse native JSON request args in crm/frappe_crm_api.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
50f2654eb1 refactor: parse native JSON request args in crm/doctype/opportunity/opportunity.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
dcc8d08521 refactor: parse native JSON request args in crm/doctype/contract_template/contract_template.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
0ff4840dcb refactor: parse native JSON request args in controllers/taxes_and_totals.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
432b4f7f86 refactor: parse native JSON request args in controllers/stock_controller.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
b5687d659f refactor: parse native JSON request args in controllers/queries.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
d8ff9b7dbb refactor: parse native JSON request args in controllers/item_variant.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
1cf5cb2425 refactor: parse native JSON request args in buying/utils.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:32 +05:30
Mihir Kandoi
c6d34a18a5 refactor: parse native JSON request args in buying/doctype/supplier_quotation/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
91c92b5e20 refactor: parse native JSON request args in buying/doctype/request_for_quotation/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
b3c64107df refactor: parse native JSON request args in buying/doctype/purchase_order/purchase_order.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
7ec052a084 refactor: parse native JSON request args in buying/doctype/purchase_order/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
5fa1599639 refactor: parse native JSON request args in assets/doctype/asset_capitalization/asset_capitalization.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
f1499b210f refactor: parse native JSON request args in assets/doctype/asset/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
97f128791c refactor: parse native JSON request args in assets/doctype/asset/asset.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
0e862d61d1 refactor: parse native JSON request args in accounts/services/child_item_update.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
fe8be87200 refactor: parse native JSON request args in accounts/doctype/unreconcile_payment/unreconcile_payment.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
4fc952badf refactor: parse native JSON request args in accounts/doctype/purchase_invoice/mapper.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
668ca62ea5 refactor: parse native JSON request args in accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
ffce7aff55 refactor: parse native JSON request args in accounts/doctype/pricing_rule/utils.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
2bc943c7e2 refactor: parse native JSON request args in accounts/doctype/pricing_rule/pricing_rule.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
2985a8b263 refactor: parse native JSON request args in accounts/doctype/pos_invoice/pos_invoice.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
3b25c2b7c2 refactor: parse native JSON request args in accounts/doctype/payment_request/payment_request.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
28770e3988 refactor: parse native JSON request args in accounts/doctype/payment_entry/payment_entry.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
ecbf8632aa refactor: parse native JSON request args in accounts/doctype/invoice_discounting/invoice_discounting.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
348e3ac4ae fix: handle native JSON request args in financial report template
Replace json.loads(object_hook=...) with frappe.parse_json and wrap each
row in frappe._dict, fixing attribute access when args arrive as native
JSON (list of dicts) instead of a JSON string.
2026-06-24 20:37:31 +05:30
Mihir Kandoi
e37c7e9b32 refactor: parse native JSON request args in accounts/doctype/dunning/dunning.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:30 +05:30
Mihir Kandoi
475cd83861 refactor: parse native JSON request args in accounts/doctype/bank_transaction/bank_transaction_upload.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:30 +05:30
Mihir Kandoi
9d8f6d4ed9 refactor: parse native JSON request args in accounts/doctype/bank_statement_import_log/bank_statement_import_log.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:30 +05:30
Mihir Kandoi
2f0367807f refactor: parse native JSON request args in accounts/doctype/bank_statement_import/bank_statement_import.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:30 +05:30
Mihir Kandoi
ec496c42b5 refactor: parse native JSON request args in accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:30 +05:30
Mihir Kandoi
a847d15748 refactor: parse native JSON request args in accounts/doctype/accounting_dimension/accounting_dimension.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:30 +05:30
Mihir Kandoi
a869b748f1 refactor: parse native JSON request args in accounts/doctype/account/chart_of_accounts/chart_of_accounts.py
Use frappe.parse_json instead of json.loads so the whitelisted endpoints
accept native JSON types (list/dict/bool) in addition to JSON strings.
2026-06-24 20:37:30 +05:30
Ankush Menat
59fe10bfbd perf: make CLI faster (#56437) 2026-06-24 13:48:28 +00:00
Ejaaz Khan
7cb03a427a fix(letter-head): guard company lookups when doc has no company field 2026-06-24 18:23:21 +05:30
rohitwaghchaure
9b0e1b61f2 fix: precision issue causing COGS in inter transfer PR (#56420) 2026-06-24 09:50:22 +00:00
Mihir Kandoi
67d314f32f Merge pull request #56421 from mihir-kandoi/gh56355
fix: exclude virtual child doctypes from deletion in transaction dele…
2026-06-24 15:17:35 +05:30
Mihir Kandoi
8bd8b28207 fix: exclude virtual child doctypes from deletion in transaction deletion record 2026-06-24 14:57:35 +05:30
ruthra kumar
314aa303e5 Merge pull request #56417 from ruthra-kumar/configurable_timeout_on_process_pcv
refactor: configurable timeout on process pcv
2026-06-24 13:27:43 +05:30
ruthra kumar
3da7eefebb refactor: patch, display depends on and json changes 2026-06-24 13:03:07 +05:30
ruthra kumar
13b6c4a165 feat(accounts): add configurable job timeout for Process Period Closing Voucher
Adds a `pcv_job_timeout` Int field (default 3600s) to Accounts Settings
so admins can tune the enqueue timeout for PCV background jobs without
a code change. All three `frappe.enqueue` calls in
`process_period_closing_voucher.py` now read this value at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 12:57:31 +05:30
Mihir Kandoi
eba851b4b5 Merge pull request #56408 from mihir-kandoi/pg-ci-fanout
ci(postgres): scheduled fan-out Postgres CI (daily + `postgres` label gate)
2026-06-24 12:57:13 +05:30
Mihir Kandoi
eae05f1907 ci: wait_for_redis fail-fast (greptile) 2026-06-24 12:37:48 +05:30
Mihir Kandoi
b919a7abff ci: wait_for_redis fail-fast (greptile) 2026-06-24 12:37:46 +05:30
Mihir Kandoi
7cd1cca2ed ci(mariadb): address greptile review 2026-06-24 12:26:46 +05:30
Mihir Kandoi
a0cc645725 ci(postgres): address greptile review 2026-06-24 12:26:44 +05:30
Nabin Hait
168c24f8f0 test: add coverage for Fixed Asset Register report
The Fixed Asset Register report had no test file. Add tests for asset value
(net purchase amount, reduced by opening accumulated depreciation), the
status (In Location) and asset category filters, and group-by-asset-category
value totals.
2026-06-24 12:23:04 +05:30
Mihir Kandoi
882f83ffaf Merge pull request #56411 from mihir-kandoi/patch-cache-v14
ci(patch): cache the v14 baseline backup instead of re-downloading every run
2026-06-24 12:19:36 +05:30
Mihir Kandoi
b74abbb9c3 ci(mariadb): use org-owned ghcr.io/frappe image 2026-06-24 12:08:03 +05:30
Mihir Kandoi
afca370fa8 ci(patch): cache the v14 baseline backup instead of re-downloading it every run 2026-06-24 11:59:25 +05:30
Mihir Kandoi
69cb1121ed ci(mariadb): drop '(ARC)' from test job name 2026-06-24 11:56:51 +05:30
Mihir Kandoi
62c401badc ci(mariadb): revert to 4 shards + cleanup 2026-06-24 11:54:50 +05:30
Mihir Kandoi
c1006e79a4 ci(postgres): cleanup — drop baseline-restore code, debug step, stale restore vars 2026-06-24 11:54:48 +05:30
Mihir Kandoi
3b8674a4a9 ci(mariadb): self-hosted fan-out CI (ARC runners, datadir bake, 8 shards) 2026-06-24 11:19:58 +05:30
Mihir Kandoi
8fd0813614 ci(postgres): scheduled fan-out Postgres CI (daily 3am IST + 'postgres' label gate) 2026-06-24 10:39:44 +05:30
Mihir Kandoi
ead694c9cb fix(manufacturing): case-sensitive variant BOM lookup on Postgres (#56407)
Reapply "fix(manufacturing): case-sensitive variant BOM lookup on Postgres"

This reverts commit 1f86b57f94.
2026-06-24 04:51:29 +00:00
rohitwaghchaure
21541e3ad3 fix: job card timer issue (#56405) 2026-06-24 09:15:54 +05:30
Mihir Kandoi
9d28bea453 Merge pull request #56394 from mihir-kandoi/pg-revert-3-commits
Revert 3 Postgres-parity commits (bom variant lookup, traceability div-by-zero, POS NULL ordering)
2026-06-24 07:27:12 +05:30
Mihir Kandoi
40960a5ff9 Merge pull request #56393 from frappe/revert-56239-pg-parity-case-insensitive
Revert "fix: case-insensitive matching match MariaDB on Postgres"
2026-06-24 07:26:01 +05:30
Ravibharathi
022845e4e7 fix(pos): remove redundant opening balance dialog onchange handler (#54591) 2026-06-24 02:20:44 +05:30
Ravibharathi
1e37c4b9ac feat(opening invoice creation tool): add project to opening invoice child row (#54662) 2026-06-24 02:16:05 +05:30
Ravibharathi
32e971e374 fix(payment_entry): recompute base amount when exchange rate changes (#56136)
Co-authored-by: ervishnucs <ervishnucs369@gmail.com>
2026-06-24 01:59:16 +05:30
Mihir Kandoi
1f86b57f94 Revert "fix(manufacturing): case-sensitive variant BOM lookup on Postgres"
This reverts commit 2e5310f8a0.
2026-06-23 23:07:54 +05:30
Mihir Kandoi
c989e424f0 Revert "fix(stock): guard traceability qty division against a zero divisor (Postgres)"
This reverts commit 3859919263.
2026-06-23 23:07:54 +05:30
Mihir Kandoi
49e3830e7f Revert "fix(selling): make POS item-price NULL ordering match across engines (Postgres)"
This reverts commit 20e6a6e149.
2026-06-23 23:07:54 +05:30
Diptanil Saha
b356dbd59e fix(budget): ambiguous error message for budget assignment validation (#56390)
Co-authored-by: Wolfram Schmidt <wolfram.schmidt@phamos.eu>
2026-06-23 17:18:08 +00:00
Mihir Kandoi
a0bbca166f Merge pull request #56389 from frappe/revert-56330-pg-queries-locate-case
Revert "fix(controllers): case-insensitive employee/lead/bom search ranking on Postgres"
2026-06-23 22:29:50 +05:30
Mihir Kandoi
4fb781ae54 Merge pull request #56388 from frappe/revert-56380-pg-get-item-price-null-order
Revert "fix(stock): make get_item_price NULL ordering match across engines (Postgres)"
2026-06-23 22:26:45 +05:30
Mihir Kandoi
2b1a477fc8 Revert "fix: case-insensitive matching match MariaDB on Postgres" 2026-06-23 22:07:48 +05:30
Mihir Kandoi
e4e6e52a4d Revert "fix(controllers): case-insensitive employee/lead/bom search ranking on Postgres" 2026-06-23 22:02:17 +05:30
Mihir Kandoi
c868de324d Revert "fix(stock): make get_item_price NULL ordering match across engines (P…"
This reverts commit 116ef44ddb.
2026-06-23 22:02:00 +05:30
Shllokkk
934b1ff7dd Merge pull request #56337 from Shllokkk/cust-supp-dashboard
fix: show contextual balance label on party dashboard for net balances
2026-06-23 21:22:35 +05:30
Diptanil Saha
0ab812c3ec feat(crm_settings): enable frappe crm data synchronization (#56268) 2026-06-23 21:14:03 +05:30
Mihir Kandoi
116ef44ddb fix(stock): make get_item_price NULL ordering match across engines (Postgres) (#56380)
get_item_price orders Item Price rows by valid_from DESC and takes LIMIT 1 to
pick the most-recent applicable price. NULL-valid_from rows are kept (the
transaction-date guard uses IfNull(valid_from, '2000-01-01')), and MariaDB
sorts NULL last for DESC while PostgreSQL defaults to NULLS FIRST — so when an
item/price_list/uom has both a dated price and a NULL-valid_from price,
PostgreSQL returns the NULL one and MariaDB the most-recent dated one, a silent
price divergence.

Wrap the sort key in IfNull(valid_from, '1900-01-01') so the NULL row sorts
last on both engines. MariaDB already placed it last for DESC, so its pick is
unchanged. Same NULL-ordering class fixed in point_of_sale.get_items (#56378).
2026-06-23 14:55:44 +00:00
Mihir Kandoi
8591a0b6ad Merge pull request #56378 from mihir-kandoi/pg-audit13-fixes
fix(postgres): three parity fixes — POS NULL ordering, traceability div-by-zero, LIKE on non-text
2026-06-23 20:10:20 +05:30
Mihir Kandoi
8960e3ff4a fix(accounts): cast non-text Account fields for LIKE filters (Postgres)
Financial Report Template calculation_formula filters are user-authored and
only validated for field existence + operator membership, not that a
like/ilike operator targets a text field. A filter such as
["is_group", "like", "1"] builds `is_group ILIKE '%1%'`; PostgreSQL has no
LIKE/ILIKE operator for a smallint/int/numeric column
(`operator does not exist: smallint ~~* unknown`) and aborts the report, while
MariaDB implicitly casts the numeric column to text and matches.

For like-family operators, cast a numeric/Check Account field to varchar
(`Cast_(field, "varchar")`), reproducing MariaDB's implicit numeric->text
coercion on both engines. Text-field filters (the normal account_name/
account_number case) are left untouched, so MariaDB output is unchanged.
2026-06-23 19:38:27 +05:30
Mihir Kandoi
3859919263 fix(stock): guard traceability qty division against a zero divisor (Postgres)
get_materials divides stock_entry_detail.qty by a CASE that returns
fg_completed_qty when it is > 0 and otherwise the injected sabb_data.qty. The
code explicitly anticipates fg_completed_qty <= 0 (the else branch), and
neither fg_completed_qty nor sabb_data.qty is constrained non-zero, so the
divisor can be 0. MariaDB returns NULL for x/0; PostgreSQL raises
`division by zero` and aborts the report. Wrapping the CASE in NullIf(..., 0)
makes the divisor NULL instead of 0 — unchanged on MariaDB, valid on Postgres.
2026-06-23 19:38:27 +05:30
Mihir Kandoi
20e6a6e149 fix(selling): make POS item-price NULL ordering match across engines (Postgres)
POS get_items keeps Item Price rows with a NULL valid_from (open-ended base
price) alongside dated rows, orders by valid_from DESC, then picks the first
matching UOM positionally via next()/[0]. MariaDB sorts NULL last for DESC, so
a dated override wins; PostgreSQL defaults to NULLS FIRST for DESC, so the
NULL-valid_from base price wins instead — the POS shows a different
price_list_rate/currency on the two engines for an item that has both an
undated standing price and a dated price.

Coalesce(valid_from, "1900-01-01") in the ORDER BY forces the NULL row to sort
last on both engines. MariaDB already placed it last for DESC, so its output is
unchanged; PostgreSQL now picks the same dated override.
2026-06-23 19:38:25 +05:30
Mihir Kandoi
3d00c93822 fix(stock): keep item-search ordering for Quality Inspection on Postgres (#56372)
The Quality Inspection item link search builds a distinct, paginated
get_query with order_by="items.item_code". frappe's db_query silently drops
the ORDER BY for a distinct query on Postgres, so with offset/limit the
results come back in a different order AND a different page slice than MariaDB.

Append the ordering to the built query instead of passing order_by: item_code
is already in the DISTINCT select list, so ORDER BY on it is valid under
DISTINCT on Postgres, and it now applies before LIMIT on both engines. MariaDB
output is unchanged (it was already ordered by item_code). The items child
field is guarded for None so a doctype without it degrades gracefully rather
than raising AttributeError.
2026-06-23 14:05:35 +00:00
Mihir Kandoi
9fdeb5f991 fix(accounts): make two Query Report SQLs valid on Postgres (loose GROUP BY + no-op ORDER BY) (#56369)
* fix(accounts): wrap loose finance_book in max() in Trial Balance (Simple) (Postgres)

The Trial Balance (Simple) Query Report selects `finance_book` but groups only
by `fiscal_year, company, posting_date, account`. PostgreSQL rejects the
non-grouped, non-aggregated column:

    column "tabGL Entry.finance_book" must appear in the GROUP BY clause or be
    used in an aggregate function

MariaDB tolerates it and returns an arbitrary finance_book per group.

Wrapping it in `max(finance_book)` keeps the row count identical (the GROUP BY
is unchanged) and makes PostgreSQL valid. Adding finance_book to GROUP BY would
split each group into N rows and change the MariaDB row count, so it is not an
option. This replaces MariaDB's previously arbitrary finance_book value with a
deterministic one (the only sanctioned MariaDB-output change); the row count is
preserved.

* fix(accounts): make Sales Partners Commission valid on Postgres (ORDER BY + div-by-zero)

The Sales Partners Commission Query Report had two PostgreSQL problems:

1. It ended with `ORDER BY "Total Commission:Currency:120"`, but the alias the
   query produces is `"Total Commission:Currency:170"` (width 170, not 120), so
   the ORDER BY never referenced a real output column. On MariaDB a double-quoted
   token is a string literal — a no-op sort that never errored. On PostgreSQL a
   double-quoted token is an identifier, so it errors with
   `column "Total Commission:Currency:120" does not exist` (and single-quoting it
   instead trips `non-integer constant in ORDER BY`). Since the clause was always
   a no-op on MariaDB, it is removed — MariaDB's group-order output is unchanged
   and the report runs on PostgreSQL.

2. `sum(total_commission)*100 / sum(amount_eligible_for_commission)` has an
   unguarded divisor: the inner query filters `total_commission`/`base_net_total`
   but not `amount_eligible_for_commission`, so a partner whose rows sum to 0
   there makes MariaDB return NULL but PostgreSQL raise `division by zero`. Wrap
   it in `NULLIF(sum(amount_eligible_for_commission), 0)` — NULL on both engines.

Both verified live on MariaDB and PostgreSQL.
2026-06-23 13:53:38 +00:00
Mihir Kandoi
4aef3aa5b3 Merge pull request #56368 from mihir-kandoi/pg-divzero-nullif-guards
fix: guard division-by-zero divisors across reports/doctypes (Postgres parity)
2026-06-23 19:21:12 +05:30
Mihir Kandoi
df6437c49d Merge pull request #56335 from aerele/fix/skip-over-allowance-for-non-stock-items
fix: skip over-allowance qty validation for non-stock items
2026-06-23 19:14:42 +05:30
Mihir Kandoi
247d574283 Merge pull request #56370 from mihir-kandoi/pg-report-bool-and-fieldcase
fix: two Postgres hard errors — Check-vs-bool and capital-cased fieldname
2026-06-23 19:04:35 +05:30
Mihir Kandoi
3b1b315bf4 Merge pull request #56364 from aerele/job-card-mandatory
fix(manufacturing): make item_code mandatory in Job Card Item
2026-06-23 19:02:16 +05:30
Mihir Kandoi
07a86b33e6 fix(stock): guard non-stock valuation-rate division against a zero divisor (Postgres)
The non-stock-item valuation rate divides Sum(base_net_amount) by
Sum(qty * conversion_factor) over Purchase Invoice Items. A line with qty 0
zeroes the divisor. MariaDB returns NULL for x/0 (the caller maps it via
`or 0.0`); PostgreSQL raises `division by zero` and aborts. Wrap the divisor in
NullIf(Sum(qty * conversion_factor), 0): unchanged on MariaDB, valid on Postgres.
2026-06-23 18:54:54 +05:30
Mihir Kandoi
abe9e8becc fix(setup): use the stored lower-case fieldname in the Sales Person lookup (Postgres)
deactivate_sales_person looks up `frappe.db.get_value("Sales Person",
{"Employee": employee})`. The Sales Person field is `employee` (lower case);
the lookup runs with ignore_permissions, so the capital-cased key reaches the
query as the column `"Employee"`. PostgreSQL matches quoted identifiers
case-sensitively and errors:

    column "Employee" does not exist

MariaDB resolves `Employee` to the `employee` column regardless of case, so
using the stored `{"employee": employee}` selects the same row on MariaDB and
is valid on PostgreSQL.
2026-06-23 18:45:27 +05:30
Mihir Kandoi
54cfeaf357 fix(selling): compare the selling Check field with 1, not a Python bool (Postgres)
Customer-wise Item Price builds `ip.selling.eq(True)`, which renders
`WHERE "selling" = true`. `selling` is a Check field (smallint on Postgres),
and PostgreSQL has no `smallint = boolean` operator:

    operator does not exist: smallint = boolean

MariaDB accepts it because `true` aliases to `1`. Comparing with the integer
`1` (`ip.selling.eq(1)`) selects identical rows on MariaDB and is valid on
PostgreSQL.
2026-06-23 18:45:26 +05:30
Mihir Kandoi
71685532bd Merge pull request #56367 from mihir-kandoi/pg-asset-depr-coalesce-reship
fix(accounts): stop coalescing a DATE with an int in Asset Depreciations report
2026-06-23 18:45:00 +05:30
Mihir Kandoi
727f8d0967 fix(stock): guard production-plan received-qty division against a zero divisor (Postgres)
update_received_qty_if_from_pp divides received_qty by (qty / fg_item_qty) over
Purchase Order Items. Both qty and fg_item_qty are Float with no non-zero
constraint, so a zero qty (or fg_item_qty) drives the divisor to 0.

MariaDB returns NULL for x/0 (dropped by the surrounding Sum); PostgreSQL
raises `division by zero` and aborts the Purchase Receipt submit/cancel.
Wrapping both divisors in NullIf(..., 0) makes the zero row contribute NULL on
both engines, leaving MariaDB output unchanged.
2026-06-23 18:41:44 +05:30
Mihir Kandoi
334f1cc6f0 fix(stock): guard incorrect-serial valuation-rate division against a zero qty (Postgres)
The Incorrect Serial No Valuation report computes
stock_value_difference / actual_qty for every matching Stock Ledger Entry. A
valuation-only Stock Reconciliation of serialized/batched stock writes an SLE
with actual_qty = 0 and a non-zero stock_value_difference, and the or_filters
(serial_no / serial_and_batch_bundle set) do not exclude it.

MariaDB returns NULL for x/0; PostgreSQL raises `division by zero` and aborts
the report. Using the get_all nested NULLIF form
{"DIV": ["stock_value_difference", {"NULLIF": ["actual_qty", 0]}]} yields NULL
on both engines, leaving MariaDB output unchanged.
2026-06-23 18:41:22 +05:30
Mihir Kandoi
d48396cb11 fix(selling): guard lost-value ratio against a zero total (Postgres)
The Lost Quotations report's lost-value ratio divides Sum(base_net_total) by
total_value, a scalar Sum(base_net_total) subquery over the same lost
quotations. If every lost quotation in the period is zero-amount, total_value
is 0 while the grouped query still returns rows.

MariaDB returns NULL for x/0; PostgreSQL raises `division by zero` and aborts
the report. Wrapping the divisor in NullIf(total_value, 0) yields the same NULL
column on MariaDB and no error on PostgreSQL. (The sibling count ratio divides
by Count >= 1 in any returned row and is unaffected.)
2026-06-23 17:49:27 +05:30
Mihir Kandoi
affd2fd95d fix(manufacturing): guard average bin valuation-rate division against a zero divisor (Postgres)
_get_avg_valuation_rate_from_bins divides Sum(stock_value) by Sum(actual_qty).
The `Count(name) > 0` guard only proves a Bin row exists; Sum(actual_qty) can
still be 0 (stock depleted, or per-warehouse quantities cancelling out), and
the outer IfNull catches only NULL, not a 0 divisor.

MariaDB returns NULL for x/0 (then IfNull -> 0.0); PostgreSQL raises
`division by zero` and aborts BOM costing. Wrapping the divisor in
NullIf(Sum(actual_qty), 0) keeps the identical 0.0 result on MariaDB and
avoids the error on PostgreSQL.
2026-06-23 17:49:26 +05:30
Mihir Kandoi
297153264b fix(accounts): guard last-GLE exchange-rate division against a zero divisor (Postgres)
calculate_exchange_rate_using_last_gle divides (debit - credit) by
(debit_in_account_currency - credit_in_account_currency). The GL row is
re-selected by (voucher_type, voucher_no, account) ordered by posting_date
WITHOUT the "(debit_in_account_currency > 0) | (credit_in_account_currency > 0)"
filter the first query used, so the chosen row can have equal/zero account-
currency amounts, making the divisor 0.

MariaDB returns NULL for x/0 (the caller maps it via `or 0.0`); PostgreSQL
raises `division by zero` and aborts. Wrapping the divisor in NullIf(divisor, 0)
yields NULL on both engines, so MariaDB output is unchanged and PostgreSQL no
longer errors.
2026-06-23 17:49:12 +05:30
Mihir Kandoi
27ec5eabc6 fix(accounts): stop coalescing a DATE with an int in Asset Depreciations report
The Asset Depreciations and Balances report tested disposal status with
IfNull(asset.disposal_date, 0) != 0 / == 0 — coalescing the DATE column
disposal_date with the integer 0. frappe.qb renders this as
COALESCE("disposal_date", 0); PostgreSQL requires COALESCE arguments to
share a type and raises:

    psycopg2.errors.DatatypeMismatch: COALESCE types date and integer
    cannot be matched

The predicate is in the WHERE/CASE of every query the report runs (both
group_by=Asset Category and group_by=Asset), so the whole report errored
on PostgreSQL. MariaDB's IFNULL(date, 0) is permissive and worked.

Replace each comparison with the null-test form already used elsewhere in
this same file: IfNull(disposal_date, 0) != 0 -> disposal_date.isnotnull(),
== 0 -> disposal_date.isnull(). Semantically identical (a stored date is
never 0), valid on both engines, MariaDB output unchanged.
2026-06-23 17:43:49 +05:30
Lakshit Jain
7b659ee6af fix: whitelist get_payment_terms api (#55850)
Co-authored-by: Abdeali Chharchhoda <abdealiking786@gmail.com>
2026-06-23 17:37:15 +05:30
Lakshit Jain
4de1064ef6 Merge pull request #56345 from vorasmit/fix-get-tax-rate
fix: whitelist `get_tax_rate`
2026-06-23 17:36:59 +05:30
Mihir Kandoi
f751f80158 ci(postgres): flag division by a possibly-zero divisor in the compat guard (#56363)
Adds the division-by-zero divergence class to the PG-compat review tooling:
on a divisor that the data can drive to 0 (e.g. Sum(a)/Sum(b)), MariaDB
returns NULL for division by zero while PostgreSQL raises `division by zero`
and aborts the query. The portable fix is to wrap the divisor in
NullIf(divisor, 0), which yields NULL on both engines (matching MariaDB).

- .greptile/config.json: add it to the "would ERROR on PostgreSQL" list.
- .github/POSTGRES_COMPATIBILITY.md: document it under §1 (hard breaks).
- .github/helper/postgres_compat.py: note it in the docstring as a
  deliberately-not-statically-checked semantic divergence (data-dependent,
  like integer-division intent), so it stays a reviewer/Greptile concern.

Tooling-only; no source query changes. The instance fix shipped in #56361.
2026-06-23 12:00:50 +00:00
Mihir Kandoi
c79febf403 fix(projects): drop redundant distinct from portal project list (Postgres ordering) (#56362)
get_project_list builds a single-table query (no joins) with fields="*",
which always selects the unique PK `name`, so distinct=True can never
deduplicate any rows. It is a no-op on the result set for both engines.

It is not a no-op on ordering, though: frappe.db drops the ORDER BY clause
for distinct queries on Postgres (Postgres requires every ORDER BY term to
appear in the select list under DISTINCT), so the website project list came
back unordered on Postgres while MariaDB returned it ordered by `order_by`.

Removing the redundant flag leaves the MariaDB result and order untouched
and restores the same ordering on Postgres.
2026-06-23 11:47:38 +00:00
Mihir Kandoi
453b5cee21 fix(stock): guard batchwise valuation-rate division against a zero divisor (Postgres) (#56361)
fix(stock): guard batchwise valuation-rate division against a zero divisor

get_valuation_rate's batchwise fallback selects
Sum(stock_value_difference) / Sum(actual_qty). When a batch's non-current
Stock Ledger Entries net to zero quantity (equal received and issued) the
divisor Sum(actual_qty) is 0. On MariaDB x/0 yields NULL and the caller's
`if last_valuation_rate and last_valuation_rate[0][0] is not None` check
falls through to the next strategy; on PostgreSQL float division by zero
raises `division by zero`, aborting the query (and the transaction).

Wrap the divisor in NullIf(Sum(actual_qty), 0) so a zero divisor yields
NULL on both engines, matching MariaDB and preserving the caller's
is-not-None fall-through. (stock_value_difference is Currency and actual_qty
is Float, so the division was already float — no integer-truncation change.)
2026-06-23 11:39:58 +00:00
pandiyan
d7e9a97f8a fix(manufacturing): make item_code mandatory in Job Card Item
The item_code field in the Job Card Item child table was optional,
allowing job cards to be saved without a raw material item linked.
Set reqd=1 in the JSON and update the Python type annotation accordingly.
2026-06-23 16:56:50 +05:30
Mihir Kandoi
2f4e78f09e ci(postgres): flag COALESCE/IfNull of a typed column with a mismatched-type literal (#56358)
ci(postgres): teach the guard about COALESCE(date, int) type mismatch

New class found by the whole-repo audit (the Asset Depreciations report fix in this PR): IfNull/Coalesce of a typed column with a different-typed literal -- e.g. IfNull(date_col, 0) -> COALESCE(date, integer), which PostgreSQL rejects (DatatypeMismatch). Added to the Greptile config and POSTGRES_COMPATIBILITY.md (not statically checkable without column types).
2026-06-23 16:52:48 +05:30
pandiyan
733e24faef test: add tests for non stock item over billing against so/po 2026-06-23 16:38:38 +05:30
Mihir Kandoi
6a237f323f Merge pull request #56349 from mihir-kandoi/pg-compat-tooling-audit-learnings
ci(postgres): teach the PG-compat tooling the audit 6-9 divergence classes
2026-06-23 14:35:33 +05:30
Mihir Kandoi
d032e93f87 Merge pull request #56344 from mihir-kandoi/pg-mps-leadtime-intdiv
fix(manufacturing): keep MPS cumulative lead-time fractional across engines
2026-06-23 13:40:18 +05:30
NaviN
d1ffac36c1 fix(payment reconciliation): honour user permissions on accounting dimensions (#55803) 2026-06-23 13:36:29 +05:30
Mihir Kandoi
331f383777 ci(postgres): match CAST AS CHAR with nested parens in the checker
The [^)]* span stopped at the first inner ')', so CAST(ABS(col) AS CHAR) slipped through. Use a non-greedy .+? with re.S; still zero production false positives (verified). Addresses review feedback.
2026-06-23 13:24:49 +05:30
Mihir Kandoi
d225532595 test(manufacturing): make MPS lead-time fixture idempotent
Delete any existing Item Lead Time for the test item before inserting, so the test is re-runnable on a shared database (addresses review feedback).
2026-06-23 13:20:58 +05:30
Nishka Gosalia
ce4e56336e Merge pull request #56350 from nishkagosalia/gh-56067
fix: handling default company in purchase transactions created from project
2026-06-23 12:37:37 +05:30
nishkagosalia
359717115f fix: company default handling in purchase transactions made from project 2026-06-23 12:33:31 +05:30
Mihir Kandoi
fc9544435e ci(postgres): teach the PG-compat tooling the audit 6-9 divergence classes
The whole-repo MariaDB<->PostgreSQL audits surfaced classes the checker and
review guide did not yet cover. Add them:

Static checker (.github/helper/postgres_compat.py) - new mechanical breaks:
- .rlike() / raw RLIKE: frappe rewrites REGEXP->~* on Postgres but NOT RLIKE.
- Cast(x, "char") / raw CAST AS CHAR: bare CHAR is character(1) on Postgres
  and truncates multi-digit values; use "varchar".
(Both flag zero production code; the only repo hit is in patches/, which the
hook already excludes.)

Greptile config + POSTGRES_COMPATIBILITY.md - new semantic/hard classes:
- aggregate (Sum/Count) selected next to bare columns with no GROUP BY at all.
- .like()/LIKE on a non-text column (bigint ILIKE) -> Cast_ to varchar.
- get_all(fields=["CapitalCase"]) identifier-case (extends the get_value case).
- bool into a Check column via qb.update().set() (extends set_value/db_set).
- int/int division: float a literal (col/1440 -> col/1440.0).
- Concat over a nullable column leaking a bare prefix on Postgres.
- clarify REGEXP/.regexp() is translated but RLIKE/.rlike() is not.
2026-06-23 12:22:22 +05:30
Nabin Hait
824415d50e test: cover AR/AP show-remarks, delivery notes and group-by-party filters
Add coverage for previously untested checkbox filters: Show Remarks (the
invoice remark appears in the row) and Show Linked Delivery Notes on the
receivable report, and Show Remarks and Group By Supplier on the payable
report.
2026-06-23 11:32:31 +05:30
Nabin Hait
d98b269033 test: cover all projected-qty components in Stock Projected Qty report
Add a test exercising the full projected_qty formula - actual + ordered +
requested + planned minus reserved, reserved-for-production, reserved-for-
subcontract and reserved-for-production-plan - and asserting each component
is surfaced as its own column.
2026-06-23 11:08:40 +05:30
Nabin Hait
8c124ed4a9 test: cover AR/AP journal-entry payments and credit notes
The receivable/payable reports lacked coverage for settling invoices via a
Journal Entry (rather than a Payment Entry) and for credit notes raised via
JE. Add: an invoice partially and fully paid via JE on the receivable side,
a standalone JE credit note showing as negative outstanding, and a supplier
invoice partially paid via JE on the payable side.
2026-06-23 11:04:17 +05:30
pandiyan
553b55b2f0 fix: skip qty over-allowance check for non-stock items only 2026-06-23 11:00:15 +05:30
Nabin Hait
e0114d56db test: cover Stock Balance include-zero and ageing checkbox filters
Add coverage for two untested Stock Balance checkbox filters: zero-balance
items are hidden unless 'include zero stock items' is on, and the stock
ageing columns appear only when 'show stock ageing data' is on.
2026-06-23 10:56:04 +05:30
Nabin Hait
ca908b69cf Merge pull request #56246 from nabinhait/commission-fields-depends-on-sales-partner
fix(selling): hide commission fields without a sales partner and stop copying them
2026-06-23 10:42:16 +05:30
vorasmit
48d5e8732b fix: whitelist get_tax_rate 2026-06-23 10:38:58 +05:30
Mihir Kandoi
28b5efcbe1 fix(manufacturing): keep MPS cumulative lead-time fractional across engines
get_item_lead_time in Master Production Schedule computes
manufacturing_time_in_mins / 1440 + purchase_time + buffer_time. As in the
MRP report, manufacturing_time_in_mins is an Int column and 1440 an int
literal, so the division truncates on PostgreSQL (720/1440 -> 0) while
MariaDB yields 0.5. The value is summed over the BOM tree, ceil'd, and
drives the planned order-release date, so it diverged by engine.

Use a float numerator (1440.0). MariaDB output is unchanged; PostgreSQL
now matches it.
2026-06-23 10:38:54 +05:30
Mihir Kandoi
b0b20edd3e Merge pull request #56343 from mihir-kandoi/pg-mrp-leadtime-intdiv
fix(manufacturing): keep MRP lead-time fractional across engines
2026-06-23 10:26:51 +05:30
Mihir Kandoi
060b0df55e Merge pull request #56342 from mihir-kandoi/pg-audit6-mariadb-corrections 2026-06-23 10:02:19 +05:30
Mihir Kandoi
75030bab0f Merge pull request #56341 from mihir-kandoi/pg-audit6-hard-errors 2026-06-23 10:02:06 +05:30
Mihir Kandoi
34293d107b Merge pull request #56339 from mihir-kandoi/pg-batch-search-groupby-pk 2026-06-23 10:01:48 +05:30
Mihir Kandoi
8a20c9f681 fix(manufacturing): keep MRP lead-time fractional across engines
get_item_lead_time computed the manufacturing lead time as
1440 / manufacturing_time_in_mins + buffer_time. Both columns are Int, so
integer/integer division truncates on PostgreSQL (1440/7 -> 205) while
MariaDB yields a decimal (205.71). The value feeds math.ceil() and then
release_date = add_days(delivery_date, -lead_time), so a user could see a
release date that differs by a day between engines.

Make the numerator a float literal (1440.0) so both engines do decimal
division. MariaDB already returned a decimal, so its output is unchanged;
PostgreSQL now matches it.
2026-06-23 10:01:20 +05:30
Mihir Kandoi
f11f8cb005 fix(regional): use correct lowercase fieldname in UAE VAT tax accounts
get_tax_accounts fetched fields=['Account'] but the UAE VAT Account fieldname is lowercase account. PostgreSQL treats the double-quoted identifier case-sensitively ('column "Account" does not exist'); MariaDB identifiers are case-insensitive so it worked there. Use the real fieldname account; output unchanged on MariaDB.
2026-06-23 09:13:52 +05:30
Mihir Kandoi
16b27ecdd1 fix(stock): group the Stock Ledger opening-balance dimension query
get_opening_balance_for_inv_dimension selected item_code and warehouse alongside Sum() aggregates with no GROUP BY, which PostgreSQL rejects ('column ...item_code must appear in the GROUP BY clause'). Add GROUP BY item_code, warehouse. The query already returns early unless a single item and warehouse is selected, so this stays one row with identical values on MariaDB while becoming valid on Postgres.
2026-06-23 09:13:51 +05:30
Mihir Kandoi
bde630b888 fix(controllers): cast idx to varchar in child-row picker for Postgres
get_filtered_child_rows searched child rows by row number with table.idx.like(...). idx is an integer column; frappe maps .like() to ILIKE on Postgres, which has no bigint ILIKE operator ('operator does not exist: bigint ~~* unknown'). Cast idx to string via frappe's Cast_ with 'varchar': a bare CAST(idx AS CHAR) is character(1) on Postgres and silently truncates a two-digit idx (11 -> '1'), dropping the row; CAST(idx AS VARCHAR) keeps the full value, and on MariaDB Cast_ rewrites to CONCAT(idx, '') matching the previous implicit coercion. MariaDB output unchanged. The test builds an order with >10 rows and searches row 11 (fails on Postgres with a char(1) cast).
2026-06-23 09:13:50 +05:30
Mihir Kandoi
9f1915800f fix(edi): make Common Code docname lookup valid on Postgres
get_docnames_for issued SELECT DISTINCT on Dynamic Link.link_name while ordering by Dynamic Link.idx, a column absent from the select list. This is a raw frappe.qb query (run via .run(), not get_all/get_list), so the ORDER BY is emitted verbatim and PostgreSQL rejects it: 'for SELECT DISTINCT, ORDER BY expressions must appear in select list'. Order by link_name (the selected, distinct column) instead; same docnames on both engines, now deterministically ordered.
2026-06-23 09:13:49 +05:30
Mihir Kandoi
dc4eee49cc fix(stock): make the batch-number picker Postgres-correct
The batch-number link picker (get_batch_no) had two Postgres-only defects in
both of its query builders (get_batches_from_stock_ledger_entries and
get_batches_from_serial_and_batch_bundle):

1. GROUP BY. They group by Stock Ledger Entry / Serial-and-Batch-Entry columns
   while selecting un-aggregated Batch-master columns (manufacturing_date,
   expiry_date, search fields). PostgreSQL only accepts that when the Batch
   primary key is in the GROUP BY, so the picker raised GroupingError. Adding
   batch_table.name (equal to the grouped batch_no via the join) keeps the
   group count - and the MariaDB result - unchanged while making it valid.

2. CONCAT over nullable dates. "MFG-"/"EXP-" labels were built with
   Concat("MFG-", manufacturing_date). When the date is NULL, MariaDB CONCAT
   returns NULL but Postgres CONCAT drops the NULL and yields a bare "MFG-"/
   "EXP-". Guard each with Case().when(date.isnotnull(), ...) so a missing date
   is NULL on both engines (matching MariaDB, fixing Postgres).

Both leave MariaDB output unchanged. test_get_batch_no_search_returns_batches
exercises both builders directly and asserts no bare "MFG-"/"EXP-" leaks;
reverting either fix makes it fail on Postgres.
2026-06-23 09:04:43 +05:30
Mihir Kandoi
b4aae9dea1 fix(crm): render Lead Details address consistently across engines
The Lead Details report concatenated address_line1 and address_line2 with
CONCAT_WS. An unfilled optional Data field is stored as '' on MariaDB but as
NULL on PostgreSQL; CONCAT_WS keeps the empty string (leaving a trailing
", ") on MariaDB while Postgres drops the NULL, so the same lead rendered a
different address on each engine.

Wrap both parts in NULLIF(part, '') so empty values are treated as NULL on
both engines: the report now produces the same clean address (no trailing
separator) everywhere.
2026-06-23 08:58:30 +05:30
Mihir Kandoi
295dec24db fix(stock): group get_picked_batches by batch and warehouse
get_picked_batches summed Serial-and-Batch-Entry qty while selecting bare
batch_no and warehouse with no GROUP BY. PostgreSQL rejects this outright:

    column "tabSerial and Batch Entry.batch_no" must appear in the GROUP BY clause

MariaDB does not error but collapses every picked row into a single result -
the grand-total qty pinned to one arbitrary batch - so the caller, which keys
the result by (batch_no, warehouse) to subtract already-picked stock, under-counts
whenever more than one batch is picked.

Add GROUP BY batch_no, warehouse so the query returns one correct row per batch
on both engines (this corrects the MariaDB result, not just Postgres validity).
2026-06-23 08:58:29 +05:30
Mihir Kandoi
edf1341f42 Merge pull request #56340 from mihir-kandoi/pg-mr-supplier-distinct-orderby
fix(stock): keep supplier-based Material Request picker valid on Postgres
2026-06-23 07:59:47 +05:30
Mihir Kandoi
90ef4f4776 fix(stock): keep supplier-based Material Request picker valid on Postgres
get_material_requests_based_on_supplier deduplicated requests with
SELECT DISTINCT (name, transaction_date, company) while ordering by
mr_item.item_code, which is not in the select list. MariaDB allows this;
PostgreSQL rejects it:

    psycopg2.errors.InvalidColumnReference: for SELECT DISTINCT,
    ORDER BY expressions must appear in select list

so the picker errored out there.

Group by the three selected columns (equivalent to the DISTINCT, so the
same set of requests is returned) and order by Min(item_code). The order
key stays item_code but is now a well-defined aggregate, making the query
valid - and the ordering deterministic and identical - on both engines.
2026-06-23 07:41:39 +05:30
Shllokkk
3251b40365 fix: show contextual balance label on party dashboard for net balances 2026-06-23 01:44:42 +05:30
Mihir Kandoi
ff737df55f Merge pull request #56336 from mihir-kandoi/pg-lint-distinct-orderby
ci(postgres): flag get_all(distinct=True, order_by=...) in the static checker
2026-06-22 23:42:11 +05:30
Mihir Kandoi
50c4ee4ccb Merge pull request #56334 from mihir-kandoi/pg-irs1099-payer-tiebreak
fix(regional): deterministic IRS-1099 payer-address pick across engines
2026-06-22 23:32:53 +05:30
Mihir Kandoi
ad237e5ec5 ci(postgres): flag get_all(distinct=True, order_by=...) in the static checker
frappe's db_query SILENTLY drops ORDER BY for distinct queries on Postgres (the ORDER BY
column must appear in the SELECT-DISTINCT list), so `get_all/get_list(distinct=True,
order_by="<col>")` is a no-op there and the result comes back unordered — the root cause of
the Sales Register, Purchase Register and Sales Analytics ordering fixes. Add an AST rule to
.github/helper/postgres_compat.py that flags this (literal order_by only; an empty order_by=""
suppression and a dynamic/variable order_by are not flagged). `# pg-ok` escape hatch as usual.

Grandfather the three pre-existing low-impact sites the rule surfaces (paging/iteration order
only, not data): job_card operation autocomplete, inventory_dimension config list, and a
work_order test loop.
2026-06-22 23:22:52 +05:30
Mihir Kandoi
fadad2d1c4 fix(regional): deterministic IRS-1099 payer-address pick across engines
get_payer_address_html picks one company address with ORDER BY (Postal DESC, Billing DESC)
LIMIT 1 and no column tie-break. When a company has two addresses of the same address_type
the two CASE keys tie, so the LIMIT-1 row is implementation-defined and MariaDB and PostgreSQL
can return a different address.name — i.e. a different payer address on the rendered IRS-1099
form for identical data.

Add a final .orderby(address.name), mirroring the sibling get_street_address_html in the same
file (which already carries the "deterministic LIMIT-1 tie-break across engines" order). The
pick is now the lexicographically-smallest name on both engines.
2026-06-22 23:13:29 +05:30
Mihir Kandoi
f026d1dac8 Merge pull request #56329 from mihir-kandoi/pg-sales-analytics-order
fix(selling): deterministic Sales Analytics order-type row order on both engines
2026-06-22 20:02:27 +05:30
Mihir Kandoi
a1ed913eba fix(selling): deterministic order-type row order in Sales Analytics on both engines
get_teams fetched distinct order_types with get_all(distinct=True, order_by="order_type").
frappe drops ORDER BY for distinct queries on postgres (db_query), so the order_by is a
no-op there and the report's order-type leaf rows are not guaranteed any order on PG
(PostgreSQL only sorts them incidentally via its DISTINCT plan). Sort in python with
key=str.casefold instead, matching MariaDB's case-insensitive collation and guaranteeing
an identical, stable order on both engines (same pattern as the Sales/Purchase Register
account-column fix). Add a test locking the sorted order-type row order.
2026-06-22 19:42:37 +05:30
Mihir Kandoi
3ed305c75c Merge pull request #56330 from mihir-kandoi/pg-queries-locate-case
fix(controllers): case-insensitive employee/lead/bom search ranking on Postgres
2026-06-22 19:30:02 +05:30
Mihir Kandoi
da4cf77d97 Merge pull request #56328 from mihir-kandoi/pg-pos-item-group-escape
fix(pos): restore item-group filtering broken by double-escaped names
2026-06-22 19:23:58 +05:30
Raffael Meyer
13f9130d42 fix: hide redundant company currency fields on transactions (#54691) 2026-06-22 15:37:29 +02:00
Mihir Kandoi
98e8d5690e fix(controllers): case-insensitive search ranking in employee/lead/bom queries on Postgres
employee_query, lead_query and bom() ranked autocomplete results with a bare
Locate(txt, col) in ORDER BY. frappe maps Locate -> strpos on Postgres, which is
case-sensitive, while MariaDB's LOCATE against a column uses the column's
case-insensitive collation. So the search-dropdown ordering diverged between engines for
mixed-case matches (row count/membership unchanged — the WHERE .like() is already ILIKE).

Wrap both Locate operands in Lower(), matching the sibling item_query/get_project_name
handlers in the same file: a no-op on MariaDB, and case-insensitive (MariaDB-faithful) on
Postgres. The existing test_queries suite stays green on both engines.
2026-06-22 19:01:18 +05:30
Mihir Kandoi
32216bd75b fix(pos): return raw Item Group names from get_item_groups (double-escape regression)
The Postgres-portability change moved the POS item-group filters to the query builder
(item.item_group.isin(...)) and frappe.get_all(["name","in",...]), which escape values
once. get_item_groups() still pre-escaped each name with frappe.db.escape(), so the
names were escaped TWICE -> `item_group IN ('''Products''')`, matching nothing. Any POS
Profile that restricts item groups returned ZERO items, on both MariaDB and Postgres.

Return raw names; the parameterized callers escape them correctly. (get_parent_item_group
also returned the quoted literal before this fix.) Add a regression test: a POS Profile
restricted to an item group must still surface that group's items — it returns 0 before
the fix and passes after, on both engines.
2026-06-22 19:00:35 +05:30
Nabin Hait
0602a22e4b test(project): cover costing and billing roll-ups
Covers the sales/billing roll-up (total_sales_amount, total_billed_amount,
gross margin) via the whitelisted update_costing_and_billing, and
consumed-material cost from a project-linked Stock Entry issue. The
purchase-cost roll-up is already covered by the Purchase Invoice tests.
2026-06-22 18:38:51 +05:30
Nabin Hait
b90a364c31 test: add Cash Flow report correctness coverage
The Cash Flow report only had a smoke test. Add correctness tests for the
indirect method: a cash sale increases net change in cash by its amount,
and a cash purchase of a fixed asset is an investing outflow that reduces
it. Both measure the delta around a single transaction so they are
independent of existing company data.
2026-06-22 18:35:35 +05:30
Nabin Hait
b82461bf0f test: add General Ledger report filter coverage
The General Ledger report's everyday filters were untested (existing tests
only covered exchange-rate revaluation and the ignore-journals/cr-dr-notes
filters). Add coverage for opening/total/closing balance rows, group/
categorize by account subtotals, and the party filter.
2026-06-22 18:30:37 +05:30
Nabin Hait
b2bae839ac test(activity-cost): cover default-cost title and duplication
Covers the no-employee path (title set to the activity type and the
default-cost duplication guard) and employee_name being fetched for the
title. Brings activity_cost.py to full coverage.
2026-06-22 18:27:00 +05:30
Nabin Hait
6ef8b41c3c test(project-template): cover dependency-task validation
A template task that depends on another task requires that dependency to
also be present in the template's task list; covers both the rejection
and the valid case.
2026-06-22 18:25:17 +05:30
Nabin Hait
674157767a test: cover Trial Balance report filters and closing-balance setting
Extend Trial Balance coverage across its filters: show zero values, show
group accounts, show net values, period closing entry for current period,
show unclosed FY P&L balances, include default finance book entries, and
the ignore_account_closing_balance setting (cached Account Closing Balance
vs recomputed-from-GL opening).
2026-06-22 18:23:29 +05:30
Nabin Hait
d694ad9428 Merge pull request #56293 from nabinhait/fix-lead-name-none-email
fix(lead): don't crash deriving lead name when only ignore_mandatory is set
2026-06-22 18:17:16 +05:30
Nabin Hait
bab97aaad0 test(timesheet): cover activity cost and billing-rate helpers
Covers get_activity_cost falling back to the Activity Type rates (and the
empty result for an unknown type), plus get_timesheet_data and
get_timesheet_detail_rate for a billable timesheet detail.
2026-06-22 18:14:40 +05:30
Nabin Hait
14b83b46ac test(task): cover bulk actions, template deps, and delete guards
Covers the whitelisted set_multiple_status and add_multiple_tasks helpers
(including the blank-subject skip), the template-task dependency
validation, the on_trash child-exists guard, and a child task
registering itself in its parent's depends_on.
2026-06-22 18:14:38 +05:30
Nabin Hait
ecfc8cc400 test: add correctness coverage for Trial Balance report
The report previously had a single dimension-filter test. Add tests using
fresh accounts: a posted journal entry lands in the period debit/credit
columns with the grand total balanced, and an entry before the from-date
rolls into the opening-balance columns.
2026-06-22 18:06:21 +05:30
Nabin Hait
eee1fdf276 fix: reset grant_commission to default 1 after tests
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-22 18:05:20 +05:30
Nabin Hait
53f0049e75 test(project): cover create_duplicate_project and set_project_status
Both are whitelisted, UI-triggered functions that had no server tests.
Covers duplicating a project with its tasks (and the same-name guard),
and bulk-setting a project plus its tasks to a terminal status (and the
invalid-status guard).
2026-06-22 18:04:53 +05:30
Nabin Hait
e59b772c36 test: add coverage for Stock Projected Qty report
The Stock Projected Qty report had no test file. Add tests for projected
qty rolling up actual + ordered, shortage qty derived from the warehouse
reorder level, and item filtering.
2026-06-22 18:00:36 +05:30
Nabin Hait
76b0123778 test(project): cover update_percent_complete for all methods
Adds assertions for the four percent_complete_method paths (Manual is
already covered), plus the status transitions: 100% flips a project to
Completed, reopening a task flips it back to Open, and a Cancelled
project keeps its status. The method was previously unasserted.
2026-06-22 17:38:23 +05:30
Nabin Hait
8955a1edb4 test: add correctness coverage for Stock Ledger report
The Stock Ledger report had a test stub with no assertions. Add tests for
in/out quantity split and running balance, opening-balance roll-up from
movements before the period, and item filtering, sharing a small
make_movements/run_report helper.
2026-06-22 17:34:31 +05:30
Nabin Hait
a120bf8363 Merge pull request #56286 from nabinhait/ci-patch-test-no-workers-during-migrate
ci: don't run background workers during patch-test migrate
2026-06-22 17:05:15 +05:30
Nabin Hait
d48cffd1a5 Merge pull request #56303 from nabinhait/test-blanket-order-multirow
test(blanket_order): cover over-ordering aggregated across rows
2026-06-22 17:04:35 +05:30
Nabin Hait
92047e896c fix(selling): carry commission_rate through Make Delivery Note / Sales Invoice
commission_rate is no_copy so it is not carried on Duplicate/amend, but the
mapper also skips no_copy fields, leaving the mapped Delivery Note / Sales
Invoice showing 0 commission until saved (it only re-fetched from the sales
partner on save). Map commission_rate explicitly in the SO->DN, SO->SI and
DN->SI mappers so it carries over immediately; Duplicate still does not copy
it.
2026-06-22 17:01:40 +05:30
Mihir Kandoi
624844d52f Merge pull request #56308 from mihir-kandoi/gh55802
fix: submittable product bundle issues
2026-06-22 16:15:59 +05:30
Nishka Gosalia
c24fc063fc Merge pull request #56309 from nishkagosalia/migrating-document-naming-setting
fix: Removing the document naming series dialog and moving to framework
2026-06-22 16:07:08 +05:30
Nabin Hait
43d2c7335d refactor(lead): name the loops in remove_link_from_prospect
The outer and inner loops both used 'd'; name them linked_prospect and lead
so the prospect/lead iteration reads clearly. No behaviour change.
2026-06-22 15:59:26 +05:30
Nabin Hait
8f69697212 fix(lead): don't crash deriving lead name when only ignore_mandatory is set
set_lead_name fell through to email_id.split('@') when a lead had no name,
company or email but ignore_mandatory was set (e.g. data import), raising
AttributeError on a None email. Only derive from email when one exists; the
lead name is then left blank, as intended for that path.
2026-06-22 15:59:25 +05:30
Nabin Hait
3f832d4ee0 Merge pull request #56247 from nabinhait/commission-rate-data-to-percent
fix(selling): make commission_rate a Percent field on Sales Person and Sales Team
2026-06-22 15:56:30 +05:30
Mihir Kandoi
d48a1e0d16 fix: address product bundle review comments 2026-06-22 15:53:52 +05:30
nishkagosalia
aa7402b1e3 fix: removing the document naming series dialog and moving to framework 2026-06-22 15:46:15 +05:30
Mihir Kandoi
a218b8db8c fix: submittable product bundle issues 2026-06-22 15:40:16 +05:30
rohitwaghchaure
9b8c363bed feat: capitalize full actual charge on stock items only for Purchase Invoice (#56223)
* feat: capitalize full actual charge on stock items only for Purchase Invoice

Extends #56102 (Purchase Receipt) to the Purchase Invoice GL: an actual
valuation charge (e.g. Freight) flagged 'Allocate Full Amount to Stock Items'
is fully capitalized onto stock/asset items only; when unchecked, only the
stock items' share of a spread-across-all-items charge is capitalized.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: aggregate GL rows per account in PI freight test

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:39:10 +05:30
Mihir Kandoi
23bbcca97e Merge pull request #56306 from frappe/codex/fix-address-portal-row
fix: link portal address rows to web form
2026-06-22 15:29:20 +05:30
Mihir Kandoi
5008b82f90 fix: link portal address rows to web form 2026-06-22 15:19:40 +05:30
Nabin Hait
9436ab7f19 Merge pull request #56294 from frappe/chore/subcontracting-test-coverage
test: Subcontracting coverage; fix service-cost mismatch by PO item
2026-06-22 15:08:34 +05:30
Nabin Hait
c38bab7e5e Merge pull request #56302 from nabinhait/test-coupon-code-validation
test(coupon_code): cover coupon validation and usage-count edges
2026-06-22 15:07:10 +05:30
Mihir Kandoi
eafb0019bf Merge pull request #56300 from mihir-kandoi/fix-party-specific-item
fix: party specific item doesnt work if there are 2 suppliers with sa…
2026-06-22 14:57:09 +05:30
Nabin Hait
2d54f651cd fix: restore apply_permission value after running test
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-22 14:32:28 +05:30
Nabin Hait
d0184e07b3 fix(selling): hide commission fields without a sales partner and stop copying them
Across Sales Order, Delivery Note, Sales Invoice and POS Invoice, the
Commission section's commission_rate, total_commission and
amount_eligible_for_commission are sales-partner commission fields:
- depends_on eval:doc.sales_partner so they only show when a Sales Partner
  is set;
- no_copy so a duplicated/amended document does not carry a stale commission
  rate or computed commission amount (the sales partner itself still copies).

depends_on is client-only, so the server-side commission calculation is
unchanged. Add a Sales Order test for the no_copy behaviour.
2026-06-22 14:32:28 +05:30
Nabin Hait
943c6d210a fix: only rewrite commission_rate rows the column change can't cast
The previous string comparison (str(raw) != str(cleaned)) rewrote every
whole-number row ('20' vs '20.0'), turning a targeted cleanup into a
full-table rewrite on Sales Team. Skip rows already holding a plain numeric
string and only fix NULL / empty / non-numeric / percent-sign values.
2026-06-22 14:28:29 +05:30
Nabin Hait
0b1d06d46d fix: handle percent-sign commission rates in migration patch
Values like "20%" or "20 %" parse to 0 via flt, which would wipe a real
rate. Strip a trailing percent sign before parsing so they migrate as 20.
2026-06-22 14:28:29 +05:30
Nabin Hait
2fe0601a2e fix(selling): make commission_rate a Percent field on Sales Person and Sales Team
commission_rate was a free-text Data field on the Sales Person master and the
Sales Team child, storing percentages as strings. Convert both to Percent.

A pre_model_sync patch sanitizes the existing values first (empty / NULL /
non-numeric -> 0, others normalised via flt) so the Data -> Percent column
change casts cleanly under strict SQL mode, where Percent is a NOT NULL
decimal column. The patch is idempotent and avoids db-specific SQL so it works
on both MariaDB and Postgres.
2026-06-22 14:28:28 +05:30
Nabin Hait
c5ff32aa2f refactor: tidy update_coupon_code_count
Drop the dead 'if coupon:' guard (get_doc would have thrown) and collapse the
duplicate increment branches into a single exhausted-check plus increment.
No behaviour change.
2026-06-22 14:27:16 +05:30
Mihir Kandoi
7d205c89ea test: add test case 2026-06-22 14:26:28 +05:30
Nabin Hait
8cb94ebedb test: avoid needless submit in SCO validation tests
Use do_not_submit=1 for the service-item and reserve-warehouse validation
tests; they only exercise in-memory validation methods, so submitting the
Subcontracting Order is unnecessary.
2026-06-22 14:25:34 +05:30
Nabin Hait
afed7884d4 test(blanket_order): cover over-ordering aggregated across rows
The over-order check sums the same item across multiple order rows. Add a
test where one item is split into two Sales Order rows against the same
blanket order and together exceed its quantity.
2026-06-22 14:23:41 +05:30
Nabin Hait
bcd850c808 Merge pull request #56240 from nabinhait/test-sales-commission-contribution
test(sales_order): cover sales partner commission and sales-team contribution
2026-06-22 14:20:47 +05:30
Nabin Hait
eaab71a99e test(coupon_code): cover coupon validation and usage-count edges
Add tests for the previously-untested branches of validate_coupon_code
(not-yet-valid, expired, maximum-use exhausted) and update_coupon_code_count
(releasing a use on cancel, and rejecting use beyond the maximum). Both
functions are now fully covered.
2026-06-22 14:20:41 +05:30
Nabin Hait
324f72ce4d Merge pull request #56156 from nabinhait/refactor-so-reservation
refactor(sales_order): simplify create_stock_reservation_entries
2026-06-22 14:18:08 +05:30
Mihir Kandoi
98f5116a09 fix: party specific item doesnt work if there are 2 suppliers with same item 2026-06-22 14:10:45 +05:30
Nabin Hait
b4c9827318 Merge pull request #56295 from nabinhait/test-lead-coverage
test(lead): cover lead details and prospect sync/unlink
2026-06-22 14:05:35 +05:30
Nabin Hait
95b82eeba8 Merge pull request #56290 from nabinhait/test-opportunity-lost-flow
test(opportunity): improve coverage (lost flow, auto-close, item details, prospect sync)
2026-06-22 14:05:06 +05:30
Nabin Hait
b26c09ce8a Merge pull request #56238 from frappe/chore/supplier-scorecard-test-coverage
test: Supplier Scorecard coverage + fix standing/on-time shipment bugs
2026-06-22 14:04:18 +05:30
Nabin Hait
19ba681e16 Merge pull request #56296 from nabinhait/opportunity-auto-close-default-days
fix(crm): drive opportunity auto-close days from CRM Settings, not a hardcoded fallback
2026-06-22 14:04:00 +05:30
Nabin Hait
1b3cde9d44 fix: minor fix in test
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-22 13:23:02 +05:30
Nabin Hait
daee9cc89c test(sales_order): cover sales partner commission and sales-team contribution
The parent Commission section (Sales Partner commission) and the Sales Team
table (Sales Person contribution) drive separate logic in
SellingController.calculate_commission / calculate_contribution. Add
integration tests on Sales Order:
- sales partner commission: total_commission = eligible amount * rate / 100,
  and the commission-rate 0..100 bound;
- sales-person allocated_amount tracks amount_eligible_for_commission
  (grant_commission gated), not gross net_total, plus the incentive math;
- the allocated-percentage must-total-100 throw;
- rejection of a disabled sales person.
2026-06-22 13:23:02 +05:30
Nabin Hait
ee0635246f refactor(sales_order): simplify create_stock_reservation_entries
The method (cyclomatic complexity C/14) mixed packed-item separation, SRE
creation and packed-item reservation. Extract _extract_packed_item_details,
_packed_items_to_reserve and _reserve_packed_items (verbatim moves). Drops
C/14 -> A/3; no C-rank function remains in the module. No behaviour change
(stock-reservation, product-bundle and pick-list reservation suites green).
2026-06-22 13:21:31 +05:30
ruthra kumar
4ba1f5214e Merge pull request #55488 from Shllokkk/authorise-set-status
fix: add validation and tests for set_status
2026-06-22 13:19:52 +05:30
Nabin Hait
fc0dee5730 Merge pull request #56291 from frappe/chore/rfq-sq-test-coverage
test: RFQ + Supplier Quotation coverage; fix broken SQ expiry task
2026-06-22 13:10:19 +05:30
Nabin Hait
f1b6a7d690 fix(crm): drive opportunity auto-close days from CRM Settings, not a hardcoded fallback
auto_close_opportunity fell back to 15 days in code when the CRM Setting was
blank (and its docstring still said 7). The field already defaults to 15, so
read the value straight from CRM Settings and add a patch to backfill 15 for
existing sites that left it blank, keeping the same auto-close schedule.
2026-06-22 13:05:50 +05:30
Nabin Hait
ef81caeb3d test: address review feedback on Supplier Scorecard tests
- assert cost-of-shipments against the PO base_amount instead of a
  hardcoded total, so it holds when conversion_rate != 1
- guard the idempotency test's fixed scorecard name against leftovers
- clarify that the eval-statement zero/None substitution is a truthiness check
2026-06-22 13:00:59 +05:30
Nabin Hait
d68f7ea9d1 fix: match Subcontracting Order service cost by purchase order item
calculate_service_costs paired the service_items and items child tables
by list index, which breaks if the tables are not index-aligned (e.g.
populate_items_table skips a service item with zero available qty),
assigning the wrong service cost or raising IndexError. Match by
purchase_order_item instead, and guard against division by zero qty.

Adds a regression test asserting service costs follow purchase_order_item
regardless of table ordering.
2026-06-22 12:56:26 +05:30
Nabin Hait
4c6b030a4b test(lead): cover lead details fetch and prospect sync/unlink
Add tests for get_lead_details and the Lead <-> Prospect lifecycle: editing a
lead syncs into its Prospect Lead row, and deleting the only lead of a
prospect removes the prospect. Lead controller coverage 65% -> 74%.
2026-06-22 12:50:25 +05:30
Nabin Hait
bbb7384ea5 test: add Subcontracting Order validation and process-loss coverage
Covers previously untested Subcontracting Order paths:
- a Subcontracting Order requires a subcontracting Purchase Order
- service items must be non-stock items
- a supplied item's reserve warehouse must differ from the supplier warehouse
- the Subcontracting Receipt mapper applies BOM process-loss to the received qty
2026-06-22 12:48:14 +05:30
Nabin Hait
017e09eaac test(opportunity): cover item details, auto-close and prospect sync
Add tests for get_item_details, auto_close_opportunity (a stale Replied
opportunity is closed, a recent one is not) and the Opportunity -> Prospect
opportunity sync. Opportunity controller coverage 62% -> 80%.
2026-06-22 12:45:12 +05:30
Mihir Kandoi
14b1250ea7 Merge pull request #56284 from mihir-kandoi/party-alias
feat: party aliases
2026-06-22 12:43:48 +05:30
Nabin Hait
dd4371e35b fix: repair broken Supplier Quotation expiry scheduled task
set_expired_status passed filters= and fieldname= kwargs that
frappe.db.set_value does not accept, so the daily scheduled task threw
TypeError on every run and quotations were never marked Expired. Pass
the filter dict as the positional docname argument, and scope it to
submitted documents so draft quotations aren't wrongly expired (matching
the selling Quotation behaviour).

Adds coverage for valid-till validation, the expiry task, and the
RFQ quote-status round-trip on submit/cancel.
2026-06-22 12:38:32 +05:30
Nabin Hait
618ee6ddeb test: add RFQ duplicate-supplier, scorecard-block and status coverage 2026-06-22 12:38:31 +05:30
Nabin Hait
61c2e7ad6e test(opportunity): cover the mark-as-lost flow
declare_enquiry_lost had almost no coverage. Add tests that marking an
Opportunity as lost records the lost reasons, competitors and detailed
reason and sets status to Lost, and that it is blocked when an active
(submitted) Quotation exists.
2026-06-22 12:33:19 +05:30
Ankush Menat
7256fc98e9 ci: Wait for processes to die (#56288) 2026-06-22 12:25:16 +05:30
Mihir Kandoi
5e16d41387 feat: party aliases 2026-06-22 12:23:44 +05:30
Nabin Hait
599b1bab60 ci: don't run background workers during patch-test migrate
The Patch Test starts the full bench (incl. workers) and then runs migrate.
Migrate enqueues orphan-link cleanup jobs (delete_dynamic_links) that the
workers pick up and process while migrate is altering tables, which
intermittently fails with MySQL 1412 'Table definition has changed, please
retry transaction'.

Start every bench process except the workers during migrate, so nothing
consumes the queue mid-migrate. Redis and the other services stay up; the
queued jobs just wait.
2026-06-22 11:30:46 +05:30
Mihir Kandoi
73459af908 Merge pull request #56283 from frappe/revert-56261-qcs-uae-regional
revert: "feat: Enhance UAE VAT Reports, UAE FTA Audit File and Add VAT Register"
2026-06-22 11:03:17 +05:30
Mihir Kandoi
cfaaf43381 Revert "feat: Enhance UAE VAT Reports, UAE FTA Audit File and Add VAT Register" 2026-06-22 10:42:57 +05:30
Bibin
7b84478c70 Merge pull request #56261 from bibinqcs/qcs-uae-regional
feat: Enhance UAE VAT Reports, UAE FTA Audit File and Add VAT Register
2026-06-22 10:32:38 +05:30
Mihir Kandoi
bae0263990 Merge pull request #56281 from mihir-kandoi/pg-greptile-config
ci(greptile): enforce MariaDB↔PostgreSQL parity in PR review
2026-06-22 09:54:53 +05:30
Mihir Kandoi
c1a3685d14 Merge pull request #56279 from mihir-kandoi/subcontracting-inward-controller-cleanup
fix(subcontracting): correct FG-warehouse validation message + controller cleanup
2026-06-22 09:38:48 +05:30
Mihir Kandoi
3e2d61262a ci(greptile): widen guide scope to SQL-bearing non-Python files; fix HAVING-alias wording
Address Greptile review:
- customContext.files scope was **/*.py only, so Query Report SQL in .js/.sql/report .json
  files didn't get the guide attached as context (the global instructions still applied).
  Widen to .py/.js/.sql/report **/*.json.
- The guide's HAVING-alias rule said "with no GROUP BY"; PostgreSQL rejects a SELECT-alias in
  HAVING regardless of GROUP BY. Reworded to match (repeat the expression, or move a
  non-aggregate predicate to WHERE).
2026-06-22 09:34:23 +05:30
Mihir Kandoi
4a690c86d2 ci(greptile): teach the review bot to enforce MariaDB↔PostgreSQL parity
The PostgreSQL server-test job is label-gated, so until it is required the Greptile
PR-review bot is the always-on guard against cross-engine breaks. Extend
.greptile/config.json with `instructions` (and a `customContext` reference to a new
guide) so every review flags new/changed queries that would error on PostgreSQL or
silently diverge from MariaDB, under the prime rule that MariaDB output must not change.

- .github/POSTGRES_COMPATIBILITY.md — the catalogue the bot (and contributors) follow:
  hard breaks (loose GROUP BY, MySQL-only funcs, UPDATE..JOIN, HAVING-on-alias,
  DISTINCT+ORDER BY, single-quoted alias, varchar bitwise OR, capital identifiers,
  set_value(Check,bool)), silent divergences (text case-sensitivity, name-lookup case,
  empty-string↔NULL, NULL ordering, ORDER BY..LIMIT 1 tiebreakers, integer division,
  distinct-drops-ORDER-BY-on-PG + casefold sorting, function-rewrite parity, UnixTimestamp
  TZ), the GROUP BY row-count trap (Max()-wrap vs add-to-GROUP-BY; FD-by-source-table),
  the InFailedSqlTransaction/savepoint rule, and the false positives NOT to flag
  (.like→ILIKE, ifnull/backtick/LOCATE/REGEXP auto-translation, MariaDB-changing tiebreakers).
- Existing disabledLabels and frappe/frappe context are preserved.
2026-06-22 09:29:19 +05:30
Mihir Kandoi
b129daedc8 refactor(subcontracting): dedup validate_manufacture consumption checks
The `skip_transfer` and transfer branches of `validate_manufacture` ran the
same per-item validation loop — look the row up or throw "not a part of",
check overconsumption, guard against duplicates, record — differing only in
the data source (SCIO Received Item vs Work Order Item), the available-qty
basis, the source-warehouse check (skip_transfer only) and the message text.

Split each branch into a small method that builds a normalised
`{item_code: {consumed_qty, available_qty}}` lookup, and share the loop via
`_validate_customer_provided_consumption`. Branch-specific throw messages are
passed as callbacks so the user-facing strings (and their translations) are
unchanged, and the order in which checks fire is preserved. Also drops the
unused `name` column from the skip_transfer query.

Adds a test for the non-skip-transfer manufacture flow (Material Transfer for
Manufacture -> Manufacture), which exercises the Work Order branch that the
existing suite — all of whose manufacture tests set skip_transfer=1 — never
covered. Full subcontracting-inward suite passes on MariaDB and PostgreSQL.
2026-06-22 09:18:45 +05:30
Mihir Kandoi
23f1fc6235 refactor(subcontracting): use f-string for fg reference search filter
`get_fg_reference_names` built its LIKE filter with old-style
`"%%%s%%" % txt`. Use an f-string (`f"%{txt}%"`) for readability; the value
is still passed as a parameterised filter, so behaviour is unchanged.
2026-06-22 08:00:15 +05:30
Mihir Kandoi
19466b24b0 perf(subcontracting): compute child idx once per insert loop
Each new child row was given `idx=frappe.db.count(...) + 1`, issuing a count
query per inserted row across three insert loops (received items on receipt,
self-procured RM on manufacture, secondary items on manufacture). Compute the
starting index once before each loop and increment a local counter, producing
the same idx sequence with a single count query.
2026-06-22 08:00:15 +05:30
Mihir Kandoi
63c5dccb4b fix(subcontracting): guard empty raw-material list before strict zip
`update_inward_order_received_items_for_manufacture` unpacks
`zip(*item_code_wh.keys(), strict=True)`. When the manufacture entry has no
raw-material rows (all rows are finished/secondary/scrap), `item_code_wh` is
empty and the unpack raises `ValueError: not enough values to unpack`.

Return early when there are no such rows, mirroring the `if secondary_items:`
guard already present in `update_inward_order_secondary_items`.
2026-06-22 08:00:15 +05:30
Mihir Kandoi
8218875733 refactor(subcontracting): fetch customer_warehouse only when needed
In `validate_manufacture`, `customer_warehouse` is read only inside the
`skip_transfer` branch but was fetched unconditionally, wasting a lookup on
the non-skip-transfer path. Move it inside the branch that uses it.
2026-06-22 08:00:15 +05:30
Mihir Kandoi
e422c4d2ab perf(subcontracting): hoist Work Order Item lookup out of transfer loop
`validate_material_transfer` ran the `Work Order Item` query and rebuilt
`wo_item_dict` inside the per-item loop, even though both depend only on
`self.work_order`. For an entry with N customer-provided rows that meant N
identical queries. Build the lookup once before the loop.

`validate_manufacture` already builds the analogous dict once up front, so
this also aligns the two methods.
2026-06-22 08:00:15 +05:30
Mihir Kandoi
a342db38de refactor(subcontracting): drop redundant scio_item_name check
In `update_inward_order_item`, the walrus assignment `scio_item_name :=` is
already part of the truthy `if` condition, so the nested `if scio_item_name:`
is always true. Remove it and dedent the body.
2026-06-22 08:00:15 +05:30
Mihir Kandoi
57f5186dff refactor(subcontracting): hoist ValueWrapper import to module level
`validate_delivery_on_save` imported `pypika.terms.ValueWrapper` inside its
per-item loop, re-running the import on every iteration. Move it to the
module-level imports.
2026-06-22 08:00:15 +05:30
Mihir Kandoi
8fc7cb0117 refactor(subcontracting): drop unused format arg in overconsumption message
The "exceeds quantity available" throw in `validate_manufacture` passes a
third positional arg (`item.transfer_qty`), but the message only has `{0}`
and `{1}` placeholders, so `str.format` silently discards it. Remove the
dead argument; no behaviour change.
2026-06-22 08:00:15 +05:30
Mihir Kandoi
48d49cdcd2 fix(subcontracting): fix format placeholders in FG warehouse validation message
`validate_manufacture` builds its "Target Warehouse for Finished Good must
be same as Finished Good Warehouse ..." message with placeholders `{1}` and
`{2}`, but only passes two positional args (indices 0 and 1). `str.format`
raises `IndexError: Replacement index 2 out of range` instead of rendering
the message, so a user who sets the wrong FG target warehouse gets an opaque
traceback rather than the intended validation error.

Renumber the placeholders to `{0}` and `{1}` to match the args.
2026-06-22 08:00:14 +05:30
Mihir Kandoi
f001d13447 Merge pull request #56278 from mihir-kandoi/pg-purchase-register-colorder
fix(accounts): keep Purchase Register account-column order identical across engines
2026-06-22 07:53:03 +05:30
Mihir Kandoi
6ef9020134 Merge pull request #56277 from mihir-kandoi/pg-sales-register-colorder
fix(accounts): keep Sales Register account-column order MariaDB-faithful on both engines
2026-06-22 07:49:49 +05:30
Mihir Kandoi
c8e294b416 Merge pull request #56276 from mihir-kandoi/pg-customer-name-suffix
fix(selling): match MariaDB's customer-name suffix extraction on Postgres
2026-06-22 07:48:20 +05:30
Mihir Kandoi
9abdf8527e Merge pull request #56275 from mihir-kandoi/pg-procurement-tracker-rowcount
fix(buying): keep Procurement Tracker one row per (PO, material_request_item) (MariaDB parity)
2026-06-22 07:47:52 +05:30
Mihir Kandoi
cf075bd67e fix(accounts): keep Purchase Register account-column order identical across engines
get_account_columns fetched the dynamic expense / unrealized-P&L account lists with
frappe.get_all(distinct=True, order_by=...). frappe silently drops ORDER BY for
distinct queries on postgres (db_query), so the generated account columns came back
in arbitrary order on Postgres while MariaDB kept them ordered — a cross-engine
parity gap (the sibling Sales Register had already moved to a python sort).

Sort the lists in python with key=str.casefold (dropping the ignored order_by) so the
column order is deterministic, case-insensitive (matching MariaDB's collation), and
identical on both engines. Add a regression test with two case-colliding expense
account names asserting the casefold column order on both engines.
2026-06-22 07:30:35 +05:30
Mihir Kandoi
e26a499923 fix(accounts): keep Sales Register account-column order MariaDB-faithful on both engines
get_account_columns sorts the dynamic income / unrealized-P&L account columns with
python sorted() (the original raw SQL used ORDER BY, which frappe drops for distinct
queries on postgres). Plain sorted() is case-sensitive (ASCII), so it reordered the
columns versus the pre-effort MariaDB output, whose ORDER BY ran under the
case-insensitive utf8mb4 collation.

Sort with key=str.casefold so the column order matches MariaDB's collation and is
identical on MariaDB and Postgres. Add a regression test with two case-colliding
account names ("aaa ..." / "ZZZ ...") that fails on case-sensitive sort and passes
after, on both engines.
2026-06-22 07:30:16 +05:30
Mihir Kandoi
53491e2008 fix(selling): match MariaDB's customer-name suffix extraction on Postgres
get_customer_name's Postgres branch extracted the PURE TRAILING digits of the
name (regexp '^.*?(\d*)$'), while the MariaDB branch uses
CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED) — the LEADING digits of the last
whitespace token. For a scanned name like "<base> - 3a" MariaDB yields 3 but
Postgres yielded NULL→0, so the next de-duplicated number (and thus the generated
Customer name) diverged between engines.

Make the Postgres branch take the last whitespace token then its leading digits,
mirroring MariaDB exactly ("X - 3a"->3, "X - 1.5"->1, "X - Foo"->0). Add a
regression test with a "<base> - 3a" name asserting the next name is "<base> - 4"
on both engines (it produced "<base> - 1" on the old Postgres regex).
2026-06-22 07:29:56 +05:30
Mihir Kandoi
29c29fd335 fix(buying): keep Procurement Tracker one row per (PO, material_request_item)
The Postgres-portability change added the Purchase Order Item PK (child.name) to
get_po_entries' GROUP BY. material_request_item is blank for PO lines not sourced
from a Material Request, so a multi-line PO previously collapsed to ONE row per
(PO, blank) on MariaDB but now produced one row PER LINE — changing the MariaDB
row count (and the add_total_row totals).

Group only by (PO, material_request_item) — the pre-effort key — and Max()-
aggregate the other selected columns so the query stays valid on Postgres while
restoring the prior one-row-per-group MariaDB output (per-column arbitrary→
deterministic, row count preserved). Add a regression test with a two-line PO
that fails on the multi-column GROUP BY (2 rows) and passes after (1 row), on
both MariaDB and Postgres.
2026-06-22 07:29:24 +05:30
Diptanil Saha
c188ed59ec fix(lead): added missing read permission check on get_lead_details (#56272) 2026-06-21 21:46:53 +00:00
MochaMind
8fef286327 fix: sync translations from crowdin (#56205) 2026-06-21 21:39:11 +00:00
Diptanil Saha
ea45d41314 fix: escape user image url on various templates (#56269) 2026-06-22 02:52:38 +05:30
Mihir Kandoi
ef53319183 Merge pull request #56267 from mihir-kandoi/pg-wo-stock-report-test
test(manufacturing): cover Work Order Stock report duplicate-item row count
2026-06-22 01:31:19 +05:30
Mihir Kandoi
b8bbcda047 Merge pull request #56265 from mihir-kandoi/pg-trends-rowcount
fix(controllers): keep Sales/Purchase Trends one row per based-on key (MariaDB parity)
2026-06-22 01:25:01 +05:30
Mihir Kandoi
d65098fe24 test(manufacturing): cover Work Order Stock report duplicate-item row count
Add a regression test for the one-row-per-item invariant: a BOM that lists the
same raw item on two lines at different qty must still be counted once in the
report ("# Req'd Items" == 1). The test fails on the pre-fix multi-column GROUP
BY (which split the item into one row per distinct stock_qty -> 2) and passes
after the fix, on both MariaDB and Postgres.
2026-06-22 01:11:01 +05:30
Mihir Kandoi
8f1c703871 fix(controllers): keep Supplier trends one row per supplier + add regression tests
The earlier parity fix aggregated the non-key descriptive columns for the Item
and Customer based-on paths but left Supplier grouping by all three selected
columns (supplier, supplier_name, supplier_group). supplier_name is a stored
per-transaction field, so historical purchase docs holding a divergent value for
the same supplier would split one supplier into multiple rows — diverging from
the original MariaDB output, which grouped by t1.supplier only.

Aggregate supplier_name with Max() and keep only supplier + the FD master column
supplier_group in GROUP BY, restoring one row per supplier on both engines.

Add regression tests for the Supplier (purchase) and Customer (sales) paths that
assert a single row per key even when stored descriptive fields diverge; both
fail on the pre-fix multi-column GROUP BY and pass after the fix, on MariaDB and
Postgres.
2026-06-22 00:58:56 +05:30
Mihir Kandoi
7f6004bfd9 Merge pull request #56128 from Henil666/fix/mt940-label-typo
fix: correct typo in Bank Statement Import MT940 label
2026-06-22 00:34:07 +05:30
Bibin
d0988dc32c fix(UAE VAT 201): bypass helper cache in tests
frappe.local is request-scoped, not test-scoped — it survives
across unit-test methods. Two tests calling get_standard_rated_
expenses_total({"company": "_Test Company UAE VAT"}) hit the
same cache key, so the second test (foreign-currency PI, expected
917.5) was seeing 250 carried over from the first.

Short-circuit @_cached on frappe.flags.in_test so each test method
queries fresh. Production callers run one execute() per request and
have the cache cleared at the top of that call, so the optimisation
still applies there.
2026-06-21 17:25:19 +00:00
Mihir Kandoi
dbd1388b40 fix(controllers): keep Sales/Purchase Trends one row per based-on key (MariaDB parity)
#56192 made the trends queries Postgres-strict-GROUP-BY-valid by widening based_on_group_by
to include the selected descriptive columns. For Item it added t2.item_name, for Customer
t1.territory (and customer_name) — but item_name is an editable per-line field and territory an
editable per-document field, not functionally dependent on the item_code/customer key. On MariaDB
(ONLY_FULL_GROUP_BY off) this SPLITS the single row per key into one row per distinct
(key, item_name)/(key, territory), so a customer transacting across two territories (or an item
with an edited item_name) now shows duplicate rows with fractured per-period subtotals.

Group by the KEY only and aggregate the non-key descriptive columns with Max(): one row per
based-on key (identical to the pre-#56192 MariaDB output) and still Postgres-valid. Supplier
columns are master-joined / fetch-locked (functionally dependent) so they stay unchanged.
2026-06-21 22:38:30 +05:30
Mihir Kandoi
6395d968ad Merge pull request #56260 from mihir-kandoi/pg-wo-stock-report-rowcount
fix(manufacturing): keep Work Order Stock report one row per item (MariaDB parity)
2026-06-21 22:21:21 +05:30
Bibin
a8b6bcacc5 fix(FTA Audit File): block regeneration from Generated state
The JS button only renders the Generate/Retry action for Draft and
Error; the REST endpoint, however, still let an authenticated caller
silently overwrite the attached CSV on a Generated FAF. Tighten the
server-side guard to match the UI lifecycle so the destructive
action has to be explicit (delete and create a new doc to regenerate).
2026-06-21 16:43:13 +00:00
Sudharsanan Ashok
130c2594e1 fix(stock): update voucher valuaion rate in sle (#55960) 2026-06-21 21:50:19 +05:30
Bibin
f78683c14b fix(UAE Regional): address greptile review findings
- Gate generate_faf() and mark_as_submitted() on write permission so
  REST callers without write access can no longer trigger state
  changes via the whitelisted endpoints.
- Drop test_generate_faf_excise_not_yet_implemented; the Excise file
  type is no longer a valid Select option, so doc.insert() now fails
  before generate_faf() is reached.
- Stream GL Entry rows in pages of GL_PAGE_SIZE to bound memory on
  multi-year exports against large companies; running balance,
  account-name cache, and totals carry across batches so output is
  byte-identical to the single-fetch implementation.
- Move the VAT 201 helper cache from a module-level dict to
  frappe.local so concurrent requests on threaded workers no longer
  race or leak data across users.
2026-06-21 15:33:26 +00:00
Bibin
73166979a2 test(FTA Audit File): drop redundant tearDown override
ERPNextTestSuite already calls frappe.db.rollback() in its base
tearDown; overriding (even with the same call) trips the
semgrep "Dont-override-teardown" rule.
2026-06-21 15:26:10 +00:00
Bibin
dffe4bd22d feat(FTA Audit File): Enhance FAF generation logic and error handling; update currency handling in VAT reports 2026-06-21 15:19:48 +00:00
Bibin
806f30fa87 refactor: FTA Audit File and UAE VAT Reports 2026-06-21 15:19:48 +00:00
Bibin
54d3200efa feat: Enhance UAE VAT Reports and Add VAT Register
- Updated the UAE VAT 201 report HTML to improve layout and styling for better readability.
- Modified the JavaScript for the UAE VAT 201 report to include additional formatting for VAT legends.
- Enhanced the Python logic in the UAE VAT 201 report to include caching for performance improvements and added calculations for net VAT due.
- Introduced a new UAE VAT Register report with filters for company, date range, document type, and item-wise details.
- Implemented SQL queries in the UAE VAT Register to fetch sales and purchase invoice data based on selected filters.
- Added a new field for "Company Name in Arabic" in the Company doctype for compliance with local regulations.
2026-06-21 15:19:39 +00:00
Mihir Kandoi
2221f2c6f1 fix(manufacturing): keep Work Order Stock report one row per item (MariaDB parity)
#56196's Postgres GROUP BY fix added bom.quantity, bom_item.stock_qty and
bin.actual_qty to the GROUP BY. bom.quantity and bin.actual_qty are pinned to a
single value by the WHERE/join, but a BOM may list the same item_code on multiple
lines with different stock_qty (validate_materials does not dedupe), so grouping by
stock_qty SPLITS the row and changes req_items/instock on MariaDB for such BOMs.

Aggregate build_qty with Max() and group by item_code only: one row per item_code
(identical to the pre-#56196 single-line result; deterministic for duplicate lines),
and Postgres-valid. MariaDB output is unchanged for the common single-line case and
its row count is restored for the duplicate-line case.
2026-06-21 19:58:49 +05:30
MochaMind
0ff0343588 chore: update POT file (#56253) 2026-06-21 14:26:56 +02:00
Mihir Kandoi
d9d94da9f5 Merge pull request #56256 from mihir-kandoi/pg-precommit-lint
ci(postgres): static pre-commit check for MySQL-only SQL
2026-06-21 17:29:15 +05:30
Mihir Kandoi
b2ee8cb1b9 ci(postgres): fix semgrep + two review findings in the checker
- semgrep: annotate the source-reading open() with # nosemgrep for the
  frappe-security-file-traversal rule (dev-only lint tool; path comes from pre-commit,
  not user input).
- bool-scan: only inspect the field *value* arg (db_set args[1]/dict args[0];
  set_value args[3]/dict args[2]) so a positional update_modified=False
  (e.g. db_set('f', 0, False)) no longer false-positives.
- # pg-ok: also honour the annotation on a multi-line call's closing paren line
  (scan one line past the node's end).
2026-06-21 17:10:34 +05:30
Mihir Kandoi
16e45c41f5 ci(postgres): drop the checker's unit test
Remove erpnext/tests/test_postgres_compat.py (and its pre-commit exclude); a unit
test for the dev-tooling lint helper isn't needed in the app test suite.
2026-06-21 17:03:51 +05:30
Mihir Kandoi
0e0575f27b Merge pull request #56243 from frappe/pg-ci-required
ci: upgrade the PostgreSQL server test workflow (opt-in via 'postgres' label)
2026-06-21 17:00:49 +05:30
Mihir Kandoi
549a24f7b9 ci(postgres): add a static pre-commit check for MySQL-only SQL
The Postgres test job is label-gated, so it does not run on every PR. This adds an
always-on pre-commit hook that statically flags the *mechanical* breaks: MySQL-only
functions (timestamp(date,time), timediff, str_to_date, date_format/add/sub,
group_concat, period_diff, SQL IF()), SHOW INDEX/TABLES/COLUMNS, single-quoted
aliases, UPDATE..JOIN, interpolated/f-string SQL carrying MySQL-isms,
set_value/db_set(<Check>, bool), and MySQL SHOW INDEX result keys.

It deliberately does NOT flag the framework auto-translations (ifnull->coalesce,
backtick/locate/REGEXP, .like()->ILIKE) nor the *semantic* divergences (loose GROUP
BY, case-sensitive ==/IN, NULL ordering, tiebreakers) — those need the test suite,
which remains the backstop. AST + structure-gated regex keep false positives near
zero (docstrings and prose skipped); '# pg-ok' exempts intentional MariaDB-only
branches. Scoped to erpnext/ excluding patches/. Includes a unit test of the checker.
2026-06-21 16:53:53 +05:30
Mihir Kandoi
f95e91323e ci(postgres): install payments app on the test site
The Postgres CI site only listed erpnext in install_apps, so the payments app
(fetched and built by install.sh via 'bench get-app payments') was never
installed on the site — leaving 'tabPayment Gateway' absent. test_payment_request
(and other payment-gateway-dependent tests) then errored on Postgres with
'relation "tabPayment Gateway" does not exist', while MariaDB passed because its
site_config already lists ["payments", "erpnext"]. Match that ordering for parity.
2026-06-21 16:19:52 +05:30
Mihir Kandoi
a46a6bf921 ci: speed up Postgres CI by disabling DB durability for the disposable test DB
Postgres fsyncs on every commit by default, which dominates a commit-heavy test suite.
Turn off synchronous_commit/fsync/full_page_writes on the throwaway CI database (reload-
time settings, no restart). MariaDB CI is unaffected (DB != postgres).
2026-06-21 16:19:52 +05:30
Mihir Kandoi
c820591089 ci: name the Postgres job distinctly so it is not a required check
The MariaDB job is named 'Python Unit Tests', and 'Python Unit Tests (1..4)' are the
required status checks on develop. Naming the Postgres matrix job the same made its
checks report under those required contexts, effectively gating every (labelled) PR on
Postgres. Rename it to 'Postgres Unit Tests' so its contexts are distinct and the
workflow stays non-required until we deliberately add it to branch protection.
2026-06-21 16:19:52 +05:30
Mihir Kandoi
57d0cebfb8 ci: make Postgres coverage upload glob explicit (codecov files) 2026-06-21 16:19:52 +05:30
Mihir Kandoi
d7eb54b153 ci: upgrade the PostgreSQL server test workflow (kept opt-in via 'postgres' label)
Bring the Server (Postgres) workflow in line with Server (MariaDB) internals while
keeping it opt-in for now: pull_request runs still require the 'postgres' label, but the
job now uses the full 4-container matrix (was 1), adds the nightly schedule /
workflow_dispatch / repository_dispatch triggers (which always run), and uploads
coverage. Builds ERPNext against frappe `develop` (PostgreSQL query-builder/ORM support
is merged there), so no fork override is needed.

The ERPNext server suite now passes on PostgreSQL and MariaDB from a single codebase;
flipping this to run on every PR / become a required check is a later, separate step.
2026-06-21 16:19:52 +05:30
Mihir Kandoi
0beb29321e Merge pull request #56251 from mihir-kandoi/pg-ci-remaining-failures
fix(postgres): resolve remaining Postgres test failures on develop
2026-06-21 16:19:28 +05:30
Mihir Kandoi
f595b3c0eb Merge pull request #56252 from mihir-kandoi/pg-savepoint-guards
fix(postgres): savepoint-guard swallow-and-continue insert paths
2026-06-21 16:16:08 +05:30
Mihir Kandoi
3cd2a36117 test(stock): tolerate timezone slack in test_heatmap_data on Postgres
get_timeline_data uses UnixTimestamp(posting_date); on Postgres that is the date's
midnight epoch in the DB session timezone, which can sit up to a day ahead of the
Python time.time() instant when the app timezone is ahead of UTC. The strict
'<= now' upper bound is therefore flaky on Postgres. Allow a day of slack on the
upper bound; MariaDB's UNIX_TIMESTAMP stays <= now so its pass/fail is unchanged.
2026-06-21 15:59:04 +05:30
Mihir Kandoi
bac4f1de52 fix(postgres): savepoint bank-account creation during company setup
create_bank_account() inserts a bank Account and swallows DuplicateEntryError
('bank account same as a CoA entry'). On Postgres the failed insert aborts the
transaction, so the rest of company setup ran against a poisoned transaction.
Take a savepoint and roll back to it in the handler. No-op on MariaDB.
2026-06-21 15:48:37 +05:30
Mihir Kandoi
0e25a77a62 fix(postgres): savepoint Plaid bank-account creation loop
add_bank_accounts() inserts a Bank Account per Plaid account in a loop. On a
duplicate the bare insert raises UniqueValidationError, which on Postgres aborts
the whole transaction; the handler only msgprint'd and continued, so the next
iteration's insert died with InFailedSqlTransaction. Wrap each iteration in a
savepoint and roll back to it in the handlers (the pattern frappe#40075 prescribes
after dropping the blanket per-insert savepoint). No-op on MariaDB.
2026-06-21 15:48:32 +05:30
Mihir Kandoi
f1a7b14e25 test(perf): Postgres-valid index introspection in test_ensure_indexes
SHOW INDEX is MySQL-only and errored on Postgres. Add a db-aware helper that reads
the leading index column from pg_index on Postgres and keeps SHOW INDEX on
MariaDB; both assert the field is the first column of some index.
2026-06-21 15:38:06 +05:30
Mihir Kandoi
b760b9d935 test(stock): savepoint around expected duplicate barcode save (Postgres)
The deliberate UniqueValidationError when re-adding a barcode aborts the
transaction on Postgres, so the next frappe.get_doc() failed with
InFailedSqlTransaction. Wrap the expected-failure save in a savepoint and roll
back to it. No-op on MariaDB.
2026-06-21 15:29:36 +05:30
Mihir Kandoi
72046d3688 test(stock): savepoint around expected duplicate Bin insert (Postgres)
The deliberate UniqueValidationError from the second Bin insert aborts the
transaction on Postgres, so the following _create_bin() (which takes its own
savepoint) failed with InFailedSqlTransaction. Wrap the expected-failure insert in
a savepoint and roll back to it, mirroring _create_bin's 'preserve transaction in
postgres' pattern. No-op on MariaDB.
2026-06-21 15:29:30 +05:30
Mihir Kandoi
b97a0c9a13 test(accounts): set Check field with int, not bool (Postgres)
set_value(Company, ..., "book_advance_payments_in_separate_party_account", True)
errored on Postgres (smallint column, boolean expression). Use 1; MariaDB unchanged.
2026-06-21 15:29:25 +05:30
Mihir Kandoi
e076a78003 fix(accounts): set Check field 'reconciled' with int, not bool (Postgres)
frappe.db.set_value(..., "reconciled", True) renders SET reconciled=true; the
column is smallint, which Postgres rejects (DatatypeMismatch). MariaDB coerces the
boolean to 1. Pass 1 so both engines store the same value.
2026-06-21 15:29:19 +05:30
Mihir Kandoi
2e5310f8a0 fix(manufacturing): case-sensitive variant BOM lookup on Postgres
_bom_contains_item() lowercased the item name and then reused that lowercased
value as a doc name in frappe.db.get_value("Item", item, "variant_of"). Doc
names are case-sensitive on Postgres, so the lowercased name matched no row,
variant_of came back NULL, and a Work Order for a variant item built from the
template's BOM was wrongly rejected with 'BOM ... does not belong to Item ...'.
Keep the original case for the Item lookup; the comparisons stay case-insensitive.
MariaDB is unchanged (its name lookup was case-insensitive either way).
2026-06-21 15:29:16 +05:30
Mihir Kandoi
07aa0fe6c1 Merge pull request #56250 from mihir-kandoi/pg-56249-review-followup
fix(stock): make get_incoming_value_for_serial_nos a staticmethod
2026-06-21 15:17:59 +05:30
Mihir Kandoi
81a0709dbd fix(stock): make get_incoming_value_for_serial_nos a staticmethod
It never references `self`. The deterministic-serial-value test added in #56249
called it as `get_incoming_value_for_serial_nos(None, sle, serial_nos)` — passing
None for self, which is fragile: a future `self.*` access would fail with an opaque
AttributeError. Declaring it @staticmethod makes the call honest
(`get_incoming_value_for_serial_nos(sle, serial_nos)`) and is backward compatible —
the method has no in-repo callers besides that test, and any `self.`-style call still
binds correctly to a staticmethod.

Addresses Greptile review feedback on #56249.
2026-06-21 14:56:29 +05:30
Mihir Kandoi
ed1261ef8d Merge pull request #56249 from mihir-kandoi/pg-test-helpers-parity
test(postgres): make test-helper SQL Postgres-valid across the suite
2026-06-21 14:26:39 +05:30
Mihir Kandoi
8a5f659681 test(postgres): make test-helper SQL Postgres-valid across the suite
The repo-wide query audit fixed runtime/source queries, but test files carry their
own raw SQL helpers that were never swept and only fail when the suite runs on
Postgres. Port the staging branch's already-green fixes for them:

- timestamp(posting_date, posting_time) (raw + qb Timestamp) -> posting_datetime /
  CombineDatetime (test_stock_ledger_entry, test_stock_balance, test_utils)
- HAVING <select-alias> -> qb .having(<expr>) (test_asset_capitalization, test_purchase_order)
- capital-cased identifiers ("Status", "Name") -> lowercase (test_delivery_note,
  test_purchase_order, test_employee)
- raw GL/SLE select helpers -> frappe.get_all / qb, with order-independent
  comparisons where account ordering is collation-dependent across engines
  (test_purchase_invoice, test_sales_invoice, test_payment_entry, test_asset,
  test_purchase_receipt, test_payment_request, test_repost_accounting_ledger,
  test_journal_entry)

All changes are test-only and behaviour-identical on MariaDB (lowercase column names
resolve the same; posting_datetime == timestamp(posting_date, posting_time); HAVING on
the expression is the same computation). Verified: the heavy modules pass on both
MariaDB and Postgres, and MariaDB output is unchanged.
2026-06-21 14:05:49 +05:30
Mihir Kandoi
362126a627 Merge pull request #56239 from mihir-kandoi/pg-parity-case-insensitive
fix: case-insensitive matching match MariaDB on Postgres
2026-06-21 13:11:21 +05:30
Nabin Hait
26d0821c93 fix: correct Supplier Scorecard standing and on-time shipment logic
Bugs surfaced while writing coverage for the scorecard engine:

- update_standing treated every band as [min, max), leaving the global
  ceiling open, so a perfect score (100) - including the no-period
  fallback - mapped to no standing. Make the top band inclusive of its
  upper bound.
- get_on_time_shipments counted PR lines where qty exactly matched the PO
  line, so on-time deliveries split across partial receipts were never
  counted while still inflating late shipments (and could push
  get_late_shipments negative). Count fully-on-time PO lines instead,
  keeping units consistent with get_total_shipments.
- validate_standings now rejects inverted bands (min >= max) and checks
  band continuity directly instead of relying on fragile float-equality
  accumulation.
- Remove dead 'crit.score = 0' after frappe.throw in calculate_criteria.
2026-06-21 12:51:07 +05:30
rohitwaghchaure
cc354c4e94 Merge pull request #56235 from mihir-kandoi/pg-remove-dead-get-batches
refactor(stock): remove dead get_batches() in batch.py
2026-06-21 12:46:22 +05:30
Mihir Kandoi
442ba48341 fix(manufacturing): case-insensitive batch_no filter in Cost of Poor Quality report
The report's batch_no filter used an exact `==`, which is case-sensitive on Postgres -- a
differently-cased batch_no missed Job Cards that MariaDB (case-insensitive collation)
matches. Add a dedicated batch_no branch wrapping both sides in Lower() (keeping the exact
match, not a substring like serial_no): MariaDB result is unchanged, Postgres now matches.
2026-06-21 11:59:57 +05:30
Nabin Hait
97acd4b33b Merge pull request #56141 from frappe/refactor/journal-entry-client-script
refactor: simplify Journal Entry client script
2026-06-21 11:54:38 +05:30
Nabin Hait
1de903143a Merge pull request #56150 from nabinhait/refactor-si-intercompany-fixedassets
refactor(sales_invoice): simplify fixed-asset and inter-company validations
2026-06-21 11:48:20 +05:30
Mihir Kandoi
db3f70c0e7 fix(stock): case-insensitive serial-no match in get_stock_ledgers_for_serial_nos
The serial-no filter used serial_batch_entry.serial_no.isin(serial_nos), which is
case-sensitive on Postgres -- a differently-cased serial no missed Serial and Batch
Entry rows that MariaDB (case-insensitive collation) matches (the OR'd regexp branch
only covers the legacy Stock Ledger Entry.serial_no text, empty for bundle-tracked
serials). Lower() both sides: MariaDB result unchanged, Postgres now matches too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:44:06 +05:30
Nabin Hait
d6c926a416 test: add Supplier Scorecard coverage for scoring engine and variables
Cover previously untested Supplier Scorecard logic:
- standings overlap/gap validation, total-score fallback, standing flag
  propagation to Supplier, period date generation, idempotent scorecard
  generation
- period scoring engine: criteria clamping, formula evaluation, weighted
  score, weight validation
- data-driven variables: in-period cost of shipments, on-time vs delayed
  shipment classification
2026-06-21 11:31:55 +05:30
Mihir Kandoi
9389ce6d9a fix(website): case-insensitive Item Variant attribute match on Postgres
get_item_codes_by_attributes compared Item Variant Attribute `attribute`/`attribute_value`
with raw equality/IN, which is case-sensitive on Postgres -- a differently-cased website
filter value missed variants that MariaDB (case-insensitive collation) matches. Lower()
both sides: MariaDB result is unchanged (already case-insensitive), Postgres now matches too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:30:29 +05:30
Mihir Kandoi
c4bbf22c62 Merge pull request #56236 from mihir-kandoi/pg-test-stock-entry-timestamp
test(stock): order get_sle by posting_datetime, not MySQL timestamp()
2026-06-21 11:20:00 +05:30
Mihir Kandoi
1772ccc61a test(stock): order get_sle by posting_datetime, not MySQL timestamp()
test_stock_entry.get_sle ordered by `timestamp(posting_date, posting_time)`, a
MySQL-only two-arg function that errors on Postgres ("function timestamp(date, time)
does not exist"), so every test using get_sle (test_fifo, test_stock_entry_qty, ...)
failed to run on Postgres. Order by the precomputed `posting_datetime` column instead
(identical value on MariaDB, valid on both engines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:00:50 +05:30
Mihir Kandoi
bc9503bdbf Merge pull request #56234 from mihir-kandoi/pg-stock-index-tests
test(stock): make Bin/Item index tests Postgres-valid (SHOW INDEX → db-agnostic helpers)
2026-06-21 10:53:48 +05:30
Mihir Kandoi
851e70b0f3 Merge pull request #56233 from mihir-kandoi/pg-timesheet-coalesce
fix(projects): use Coalesce for timesheet portal sales_invoice (not bitwise OR)
2026-06-21 10:51:25 +05:30
Mihir Kandoi
345cbc97e1 refactor(stock): remove dead get_batches() in batch.py
batch.get_batches(item_code, warehouse, ...) was added by #55647 and has no callers
anywhere in erpnext, frappe, or payments (not whitelisted, not referenced from JS/hooks).
It is also obsolete: it joins Stock Ledger Entry on `batch_no`, which the Serial and
Batch Bundle system no longer populates, so it returns nothing even on MariaDB. Its
query was additionally Postgres-invalid (GROUP BY batch_id with ORDER BY expiry_date/
creation -> GroupingError, since batch_id is not the primary key).

Remove the dead function (and its now-unused CurDate/Sum import) rather than fix a query
that nothing can reach. Live batch-quantity lookups go through get_batch_qty() /
get_auto_batch_nos(), which use the bundle model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:36:09 +05:30
Mihir Kandoi
dd2f3d42a5 Merge pull request #56231 from mihir-kandoi/pg-rfq-transaction-list
fix(controllers): fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT)
2026-06-21 10:35:33 +05:30
Mihir Kandoi
a927397eac Merge pull request #56230 from mihir-kandoi/pg-lost-quotations-intdiv
fix(selling): keep Lost Quotations % fractional on Postgres (integer division)
2026-06-21 10:29:28 +05:30
Mihir Kandoi
5a0e7f57a3 fix(projects): use Coalesce for timesheet portal sales_invoice (not bitwise OR)
get_timesheets_list selected `timesheet.sales_invoice | detail.sales_invoice`,
intending COALESCE (pick the parent timesheet's invoice, else the detail's) -- the
original raw SQL was COALESCE(ts.sales_invoice, tsd.sales_invoice). pypika's `|` is a
bitwise OR, not a coalesce:

- Postgres: `varchar | varchar` -> "operator does not exist" (hard error).
- MariaDB: bitwise OR coerces the operands to integers; with a NULL detail invoice the
  result is NULL, so the portal showed no invoice even when the timesheet was billed.

Replace with Coalesce(table.sales_invoice, child_table.sales_invoice).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:27:14 +05:30
Mihir Kandoi
9fe2202f39 test(stock): use db-agnostic index introspection in Item index test
test_index_creation used `frappe.db.sql("show index from tabItem")` and the MySQL-only
result key "Column_name". "SHOW INDEX" errors on Postgres, so the test could not run
there. Use the db-agnostic frappe.db.get_column_index("tabItem", column) (checking both
unique and non-unique single-column indexes) instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:25:32 +05:30
Mihir Kandoi
f66ef869fc test(stock): use db-agnostic index introspection in Bin index test
test_index_exists used `frappe.db.sql("show index from tabBin ...")`. "SHOW INDEX"
is MySQL-only syntax and errors on Postgres (syntax error at "from"), so the test
could not run there. Use the db-agnostic frappe.db.has_index("tabBin",
"unique_item_warehouse") instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:25:30 +05:30
Mihir Kandoi
42fffc4857 Merge pull request #56227 from mihir-kandoi/pg-general-ledger-alias
fix(accounts): General Ledger remarks alias Postgres-valid
2026-06-21 10:19:25 +05:30
Mihir Kandoi
de70333f8f Merge pull request #56229 from mihir-kandoi/pg-lcv-having
fix(stock): make Landed Cost Voucher vendor-invoice query Postgres-valid (HAVING→WHERE)
2026-06-21 10:18:54 +05:30
Mihir Kandoi
404d4413b5 Merge pull request #56228 from nishkagosalia/st-71960
fix: disarding stock entry fix
2026-06-21 10:16:42 +05:30
Mihir Kandoi
a7d9078bf4 fix(controllers): fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT)
rfq_transaction_list had two defects introduced when it was converted to the query
builder:

1. `party.supplier == party[0]` compared supplier to a column literally named "0"
   (a stray index on the DocType, not the intended `parties[0]` value). This renders
   as `supplier = \`0\`` / `supplier = "0"` and errors on BOTH engines
   (MariaDB: Unknown column '0'; Postgres: column "0" does not exist), so the
   supplier portal RFQ list was completely broken.
2. SELECT DISTINCT ordered by `creation`, which is not in the select list. Postgres
   rejects this ("for SELECT DISTINCT, ORDER BY expressions must appear in select list").

Compare against `parties[0]` and add `creation` to the select list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:14:42 +05:30
Mihir Kandoi
0e88f59196 Merge pull request #56226 from mihir-kandoi/pg-item-variant-update-join
fix(controllers): make update_variant_attribute_values Postgres-valid (drop UPDATE..JOIN)
2026-06-21 10:13:50 +05:30
Mihir Kandoi
ed6a682779 fix(selling): keep Lost Quotations % fractional on Postgres (integer division)
The "Lost Quotations %" column computed Count(distinct) / total_quotations * 100,
where both operands are integers. Postgres does integer division on int/int, so any
group that is a strict minority of the total truncated to 0 (e.g. 1 of 4 -> 0%);
MariaDB always divides as decimal. Multiply by 100.0 before dividing so the division
is done in floating point on both engines.

The "Lost Value %" column already divided Sum(Currency)/Sum(Currency) (numeric), so it
was unaffected; left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:09:45 +05:30
Mihir Kandoi
016097da2e Merge pull request #56225 from mihir-kandoi/pg-projects-pg-validity
fix(projects): Project timeline GROUP BY Postgres-valid
2026-06-21 10:04:08 +05:30
Mihir Kandoi
acbf453def fix(accounts): make General Ledger remarks alias Postgres-valid
When Accounts Settings -> general_ledger_remarks_length is set, the GL report
adds `substr(remarks, 1, n) as 'remarks'` to its raw SQL. Postgres treats a
single-quoted column alias as a string literal and raises a syntax error, so
the General Ledger report is broken on Postgres whenever that setting is on.

Use a bare alias (`as remarks`). substr() itself is portable.

Adds a test that sets general_ledger_remarks_length and runs the report,
asserting it executes (and returns rows) on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:58:06 +05:30
Mihir Kandoi
7f8fa5b5a2 fix(stock): make Landed Cost Voucher vendor-invoice query Postgres-valid
get_vendor_invoice_query filtered unclaimed invoices with
.having(unclaimed_amount > 0), but the query has no GROUP BY/aggregate and
unclaimed_amount is a SELECT alias. Postgres rejects HAVING on a SELECT alias
(and HAVING without GROUP BY on a non-aggregated column); MariaDB allowed it.
Move the threshold into WHERE on the underlying expression.

Behaviour is identical on MariaDB (same rows); fixes a hard error on Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:55:51 +05:30
Mihir Kandoi
3d9b704730 Merge pull request #56224 from mihir-kandoi/pg-production-planning-report
fix(manufacturing): Production Planning report GROUP BY Postgres-valid
2026-06-21 09:54:49 +05:30
nishkagosalia
debe1855c6 fix: disarding stock entry fix 2026-06-21 09:54:20 +05:30
Mihir Kandoi
598864f0be fix(controllers): make update_variant_attribute_values Postgres-valid (drop UPDATE..JOIN)
update_variant_attribute_values (propagates renamed Item Attribute Values to
variant items) used a qb UPDATE ... JOIN. Postgres has no UPDATE..JOIN syntax,
so renaming an Item Attribute Value errored on Postgres.

Rewrite as a correlated UPDATE that restricts to variant items via a subquery
on the parent (item_variant_table.parent.isin(variant Items)) instead of
joining the Item table. MariaDB behaviour is unchanged.

Covered by the existing test_item.test_rename_attribute_value_updates_variants
and test_swapped_attribute_value_renames_update_variants, which errored on
Postgres before and now pass on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:46:43 +05:30
Mihir Kandoi
a483177690 fix(projects): make Project timeline GROUP BY Postgres-valid
get_timeline_data grouped Timesheet Detail by Date(from_time) but selected
UnixTimestamp(from_time) (the full timestamp, ungrouped). MariaDB
arbitrary-picks a row's timestamp; Postgres rejects it ("must appear in the
GROUP BY clause"), so the Project timeline (calendar heatmap) is broken on PG.

Select UnixTimestamp(Date(from_time)) — the day's epoch — which is the
timeline key and matches the GROUP BY. CurDate() - Interval(years=1) is
portable and kept as-is.

Adds a test (no coverage existed) that records a timesheet against a project
and asserts get_timeline_data returns day-bucketed counts, on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:44:48 +05:30
Mihir Kandoi
469d58d1f4 fix(manufacturing): make Production Planning report GROUP BY Postgres-valid
get_purchase_details grouped Purchase Order Item by (item_code, warehouse)
while selecting `qty` ungrouped/unaggregated. MariaDB arbitrary-picks one
row's qty; Postgres rejects the query ("must appear in the GROUP BY clause"),
so the report is broken on Postgres.

Sum the qty per item+warehouse ({"SUM": "qty"}). The column is the "Arrival
Qty" (quantity on order arriving) display figure; summing the open PO lines is
the meaningful planning number, and is deterministic vs MariaDB's arbitrary
single-line pick (which only differed when an item+warehouse had multiple open
PO lines).

Adds a test (no test file existed) that creates a Work Order plus two PO lines
for a BOM raw material and asserts the report runs and reports arrival_qty = 7
(3 + 4), on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:35:56 +05:30
Mihir Kandoi
f8359c91b2 Merge pull request #56221 from mihir-kandoi/pg-stock-entry
fix(stock): convert get_used_alternative_items to query builder (Postgres)
2026-06-21 08:09:27 +05:30
Mihir Kandoi
59dd3fe84e Merge pull request #56218 from mihir-kandoi/pg-stock-ledger
fix(stock): stock_ledger raw SQL → qb + case-insensitive serial matching (Postgres)
2026-06-21 08:00:17 +05:30
Mihir Kandoi
100eefc146 Merge pull request #56219 from mihir-kandoi/pg-repost-item-valuation
fix(stock): Repost Item Valuation dedup Postgres-valid (TIMESTAMP → CombineDatetime)
2026-06-21 07:59:23 +05:30
Mihir Kandoi
51448a2bda Merge pull request #56217 from mihir-kandoi/pg-controllers-buying-itemvariant-trends
refactor(controllers): buying_controller + item_variant + trends Postgres validity
2026-06-21 07:58:38 +05:30
Mihir Kandoi
d707fb541d Merge pull request #56216 from mihir-kandoi/pg-controllers-queries
refactor(controllers): queries.py search handlers raw SQL → qb (Postgres)
2026-06-21 07:55:35 +05:30
Mihir Kandoi
ea025b6b61 Merge pull request #56211 from mihir-kandoi/pg-project-update-daily-reminder
fix(projects): repair project_update daily_reminder + convert to ORM (Postgres)
2026-06-21 07:53:30 +05:30
Mihir Kandoi
025f0db7d7 Merge pull request #56215 from mihir-kandoi/pg-controllers-budget-subcon
fix(controllers): budget GROUP BY + subcontracting bool-OR Postgres validity
2026-06-21 07:52:00 +05:30
Mihir Kandoi
0d8abba0d8 Merge pull request #56214 from mihir-kandoi/pg-controllers-return-stock
refactor(controllers): sales/purchase return + stock_controller raw SQL → qb/ORM (Postgres)
2026-06-21 07:51:13 +05:30
Mihir Kandoi
f8ae4f99af Merge pull request #56212 from mihir-kandoi/pg-controllers-selling-status-website
refactor(controllers): selling/status_updater/website raw SQL → qb/ORM (Postgres)
2026-06-21 07:46:39 +05:30
Mihir Kandoi
f8f6c444c8 Merge pull request #56213 from mihir-kandoi/pg-customer-name-pg-extract
fix(selling): make Customer name de-duplication work on Postgres
2026-06-21 07:45:37 +05:30
Mihir Kandoi
a1f7bf8195 Merge pull request #56210 from mihir-kandoi/pg-authcontrol-boot
refactor(postgres): Authorization Control + startup boot raw SQL → qb/ORM
2026-06-21 07:44:55 +05:30
Mihir Kandoi
08dd8cb9da Merge pull request #56209 from mihir-kandoi/pg-stock-batch-report-item-attribute
fix(stock): Available Batch report GROUP BY + ItemAttribute raw SQL→qb (Postgres)
2026-06-21 07:43:07 +05:30
Mihir Kandoi
f14610e31b Merge pull request #56208 from mihir-kandoi/pg-work-order-stock-report-groupby
fix(manufacturing): make Work Order Stock report GROUP BY Postgres-valid
2026-06-21 07:42:06 +05:30
Mihir Kandoi
3ce0c23513 Merge pull request #56220 from mihir-kandoi/pg-item
fix(stock): convert item.py raw SQL → qb/ORM (Postgres)
2026-06-21 07:36:59 +05:30
Mihir Kandoi
e8acc00921 Merge pull request #56207 from mihir-kandoi/pg-buying-reports-groupby
fix(buying): make Procurement Tracker & PO Analysis reports Postgres-valid (GROUP BY)
2026-06-21 07:32:58 +05:30
Mihir Kandoi
74368bc744 fix(stock): convert get_used_alternative_items to query builder
get_used_alternative_items built its WHERE with f-string interpolation of
subcontract_order / subcontract_order_field / work_order (a SQL-injection risk)
and used a raw implicit comma cross-join. Convert to frappe.qb with an
inner_join on sted.parent == ste.name and parameterised conditions. The raw
SELECT listed sted.conversion_factor twice; the qb version selects it once.
Engine-portable and MariaDB-identical.

Surgical re-apply: the rest of stock_entry.py (the services/ package layout and
other develop-only logic) is untouched.

Adds a test that substitutes an alternative item in a work order's transfer
entry and asserts get_used_alternative_items returns the mapping, on MariaDB
and Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:32:54 +05:30
Mihir Kandoi
b85c79c3e0 fix(stock): convert item.py raw SQL to qb/ORM (Postgres-valid)
Convert the raw frappe.db.sql statements in Item to frappe.qb / ORM:
validate_barcode duplicate check (-> frappe.get_all), stock_ledger_created
(-> frappe.db.exists), update_item_price + the BOM/BOM Item/BOM Explosion
description updates + check_stock_uom_with_bin's Bin UOM update (-> frappe.qb
.update), on_trash Bin/Item Price deletes (-> frappe.db.delete),
check_stock_uom_with_bin's bin lookup (-> frappe.get_all with or_filters), and
get_uom_conv_factor's self-join (-> frappe.qb).

The one genuine Postgres break is validate_duplicate_item_in_stock_reconciliation:
its raw query used `HAVING records > 1`, referencing the SELECT alias, which
Postgres rejects. The qb version uses `HAVING Count("*") > 1`.

Surgical re-apply (not a whole-file port): develop's opening-stock-reconciliation
flow (set_opening_stock / create_opening_stock_reconciliation /
make_opening_stock_entry) is preserved, and get_timeline_data keeps develop's
CurDate()-Interval form (valid on both engines), so the Interval/CurDate/
SerialBatchCreation imports are retained.

Verified: test_item 38/38 on MariaDB. Added a merge-rename test exercising the
HAVING query (validate_duplicate_item_in_stock_reconciliation) which passes on
MariaDB and Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:30:07 +05:30
Mihir Kandoi
3f360dde3a fix(stock): convert stock_ledger raw SQL to qb + case-insensitive serial match (Postgres)
Convert four raw frappe.db.sql statements to frappe.qb:
- set_as_cancel (UPDATE -> frappe.qb.update)
- the invalid-serial-no incoming_rate lookup
- get_valuation_rate's last-valuation lookup
- get_future_sle_with_negative_qty

The serial-no comparisons (invalid-serial lookup and the get_stock_ledger_entries
condition builder, which stays raw) are wrapped in lower()/Lower() so serial
matching is case-insensitive on Postgres too -- MariaDB's collation already is,
so this is a no-op there. Deterministic creation/name tiebreakers are added to
the "ORDER BY posting_date DESC LIMIT 1" lookups so Postgres picks the same row
MariaDB did.

Surgical re-apply (not a whole-file port): develop's reposting valuation-recalc
clause (`recalculate_valuation_rate`) in update_entries_after and the
already-shipped Min()-wrapped get_items_to_be_repost GROUP BY are preserved. The
dynamic-condition / row-locking raw queries (get_previous_sle,
get_stock_ledger_entries builder, get_future_sle_with_negative_batch_qty, the
qty_shift UPDATE) are intentionally left raw.

Verified: full test_stock_ledger_entry suite 22/22 on MariaDB; added focused
tests for set_as_cancel / get_valuation_rate / get_future_sle_with_negative_qty
that pass on MariaDB and Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:08:21 +05:30
Mihir Kandoi
be213d9d3d fix(stock): make Repost Item Valuation dedup Postgres-valid (TIMESTAMP→CombineDatetime)
deduplicate_similar_repost used a raw UPDATE with the MySQL-only two-arg
TIMESTAMP(posting_date, posting_time) constructor, which is invalid on Postgres.

Convert the UPDATE to frappe.qb and replace TIMESTAMP() with CombineDatetime
on the column (portable, and preserves the original NULL semantics so rows with
a NULL posting_time stay excluded); the right-hand side is this document's own
always-set posting datetime, computed in Python via get_combine_datetime to
avoid wrapping literals in a SQL datetime function.

Surgical re-apply: develop's recalculate_valuation_rate field /
_recalculate_valuation_rate method / repost() branch are left intact.

The existing test_repost_item_valuation.test_deduplication directly exercises
this UPDATE; it errors on develop's Postgres and now passes on MariaDB and
Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:06:16 +05:30
Mihir Kandoi
d065a18d16 fix(controllers): make trends queries Postgres-valid (SUM(CASE), GROUP BY)
The *_trends reports (Sales/Purchase Order/Invoice, Delivery Note, etc.) built
raw SQL that is invalid on Postgres:

- `SUM(IF(...))` -> `SUM(CASE WHEN ... ELSE NULL END)` (IF is MySQL-only).
- Loose GROUP BY: each based_on `group by` listed only the key column while the
  SELECT also returned name/territory/group/currency columns. Widen the GROUP BY
  to include every selected non-aggregated column so the query is valid on
  Postgres.
- Add a based_on_key (the first group-by column) for the group-by detail
  subqueries, which equate against a single column (a multi-column group_by
  spliced into an equality produced malformed SQL on both engines).

Behaviour note: widening the GROUP BY can split one based-on group into multiple
report rows when the snapshot columns (territory, renamed customer/item) differ
across transactions, vs MariaDB's previous one-arbitrary-row-per-group. Grand
totals are unchanged (calculate_total_row); per-group subtotals become
deterministic partial sums. This is the accepted widen-vs-arbitrary-pick
tradeoff.

Adds a test (no test file existed) running Sales Order Trends with a group_by,
exercising the widened GROUP BY / based_on_key / SUM(CASE) on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:02:29 +05:30
Mihir Kandoi
af2f53bee1 refactor(controllers): convert make_variant_item_code lookup to query builder
make_variant_item_code used a raw frappe.db.sql left join over Item Attribute /
Item Attribute Value. Convert to frappe.qb. The attribute_value comparison
casts the param with cstr() so Postgres does not error on `varchar = numeric`
for numeric attributes (where that side is irrelevant, since numeric_values == 1
already satisfies the OR). MariaDB-identical.

Surgical re-apply: develop's get_attribute_value_renames /
update_variant_attribute_values helpers and the Case import are preserved.
Covered by test_item_variant on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:02:29 +05:30
Mihir Kandoi
08f39c5345 refactor(controllers): convert BuyingController raw SQL lookups to ORM
- Asset Movement deletion: raw implicit-join select -> frappe.get_all on
  Asset Movement Item (pluck="parent").
- validate_item_type: raw `name in (...)` select -> frappe.get_all with an
  `in` filter (pluck="item_code").

Both are engine-portable, MariaDB-identical. Surgical re-apply: develop's
actual-tax distribution rewrite (distribute_actual_tax_amount / get_tax_details)
is preserved (the staging branch predated it).

validate_item_type runs on every Purchase Receipt validation (covered by
test_asset.test_purchase_asset on both engines); the Asset Movement deletion
is covered by the asset cancellation flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:02:29 +05:30
Mihir Kandoi
957f9d866a refactor(controllers): convert queries.py search handlers to qb/ORM (Postgres)
Convert eight raw frappe.db.sql search handlers to frappe.qb / frappe.qb.get_query
(which applies user-permission match conditions): employee_query, lead_query,
tax_account_query, bom, warehouse_query, get_batch_numbers, get_purchase_receipts
and get_purchase_invoices. Removes the MySQL-only get_match_cond/get_filters_cond
string building and ifnull usage.

The genuine Postgres break is get_project_name: it used CustomFunction("IF")
which emits a literal IF() (invalid on Postgres). Switch it to Case().

Surgical re-apply (not a whole-file port): develop's case-insensitive
Lower() ordering in item_query and get_project_name is preserved (the staging
branch reverted it), item_query is otherwise left untouched, and the Lower
import is retained.

Existing test_queries tests cover the converted handlers and now pass on
Postgres (test_project_query errors on develop). Adds smoke tests for the
three previously-untested handlers (batch numbers / purchase receipts /
purchase invoices).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:50:08 +05:30
Mihir Kandoi
8138f5aecd refactor(controllers): convert StockController future-SLE/GL checks to qb/ORM
- make_gl_entries_on_cancel: raw GL Entry existence select -> frappe.db.exists.
- future_sle_exists: raw GROUP BY count -> frappe.qb Count with Criterion.any,
  and get_conditions_to_validate_future_sle builds qb Criterion objects
  (warehouse == x & item_code.isin(...)) instead of escaped SQL strings.

Parity-preserving and valid on Postgres. Surgical re-apply: develop's
check_item_quality_inspection fix (`return items if doctype == "Stock Entry"
else []`) is preserved (the staging branch predated and would have reverted
it).

Adds a test asserting future_sle_exists detects a later SLE for the same
item/warehouse on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:40:30 +05:30
Mihir Kandoi
465446bb79 refactor(controllers): convert sales/purchase return lookups to qb/ORM
validate_returned_items used a raw frappe.db.sql with a string-built column
list (and a separate Packed Item select); get_already_returned_items used a
raw GROUP BY sum. Convert both to frappe.get_all / frappe.qb (Sum(Abs(...))
with an explicit groupby). The qb GROUP BY mirrors the original
`group by item_code, <field>`, so it is parity-preserving (not a behaviour
change) and valid on Postgres.

Surgical re-apply: develop's `is_debit_note = 0` credit-note fix in
make_return_doc is preserved (the staging branch predated and would have
reverted it).

Adds a test (Delivery Note -> sales return) exercising validate_returned_items
and get_already_returned_items on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:40:30 +05:30
Mihir Kandoi
7793e31e4e fix(projects): repair project_update daily_reminder and convert to ORM
daily_reminder/email_sending used raw frappe.db.sql with two portability
and correctness problems:

- The update query selected `progress` and `progress_details` from
  `tabProject Update`, but those columns do not exist on the Project
  Update doctype, so the query raised on BOTH MariaDB and Postgres
  (the function is whitelisted-only, so the bug was latent). Drop the
  non-existent columns and the corresponding "Project Status"/"Notes"
  cells from the summary table.
- `DATE_ADD(CURRENT_DATE, INTERVAL -1 DAY)` (MySQL-only) and a
  `CURRENT_DATE` Holiday lookup are not valid on Postgres.

Convert to ORM: frappe.get_all for Project/Project Update/Project User,
frappe.db.count for drafts, frappe.db.exists for the holiday check, and
add_days(today(), -1) for the date filter. Also str() the frequency in the
message so a NULL/empty frequency (Postgres returns None) does not raise.

Adds a test (the file was an empty stub) that creates a project + an update
dated yesterday and asserts the reminder finds it and runs end to end on
both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:33:47 +05:30
Mihir Kandoi
147a8672b4 fix(controllers): cast overproduced-qty flag to bool in subcontracting Case
The max-allowed-qty Case used `... | ValueWrapper(allow_delivery_of_overproduced_qty)`
where the flag is an int (0/1). Postgres rejects `OR <integer>` ("argument of
OR must be type boolean"). Wrap it in bool() so the literal renders as
true/false. MariaDB behaviour is unchanged.

Surgical: only the bool() wrap is applied; develop's weighted-average rate
logic and the internal/whitelisted status-helper split are left intact (the
staging branch predated both).

Covered by test_subcontracting_inward_order.test_over_production_delivery,
which now passes on Postgres and is unchanged on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:28:12 +05:30
Mihir Kandoi
f95e32a581 fix(controllers): make budget requested-amount aggregate Postgres-valid
The Material Request requested-amount query selects
`Sum(stock_qty - ordered_qty) * mri.rate` -- an implicit aggregate with no
GROUP BY, where mri.rate is neither grouped nor aggregated. MariaDB
arbitrary-picks the rate; Postgres rejects it ("must appear in the GROUP BY
clause"). Wrap the rate in Max(mri.rate) so the SELECT is a pure aggregate.

Behaviour note: for matched MR items with differing rates, Max() picks the
highest (vs MariaDB's arbitrary single rate). The underlying Sum(qty) * rate
is a pre-existing single-rate aggregation; this preserves it under the
accepted arbitrary-pick convention.

Covered by erpnext.accounts.doctype.budget.test_budget
.test_monthly_budget_crossed_for_mr, which now passes on Postgres (it errors
on develop) and is unchanged on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:28:11 +05:30
Mihir Kandoi
c4d2228b36 refactor(controllers): convert website_list_for_contact currency lookup to ORM
get_list_context built the enabled-currency symbol map with a raw
frappe.db.sql select. Convert to frappe.get_all (as_list). MariaDB-identical.

Adds a test asserting the currency-symbol map is built and contains a known
enabled currency, on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:18:58 +05:30
Mihir Kandoi
9c3f09927f fix(selling): make Customer name de-duplication work on Postgres
get_customer_name's Postgres branch used `Substring(Customer.name, r"\d+$")`,
but pypika's Substring is a start/length function, not a regex extractor, so
it raised `TypeError: Substring.__init__() missing 1 required positional
argument: 'stop'` at query-build time. Creating a second Customer with an
existing name therefore failed outright on Postgres.

Extract the trailing digits with regexp_replace + NULLIF + CAST instead. A
non-numeric trailing token strips to an empty string, which NULLIF turns into
NULL so MAX() skips it and COALESCE floors to 0 -- matching MariaDB's
CAST(... AS UNSIGNED) -> 0. MariaDB behaviour is unchanged (its branch is
untouched). Drops the now-unused Substring import.

Adds a test that creates "<name>" and "<name> - 3" and asserts the next
de-duplicated name is "<name> - 4" on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:16:58 +05:30
Mihir Kandoi
083858d450 refactor(controllers): make StatusUpdater Postgres-valid (ifnull→coalesce, raw SQL→qb)
- Replace MySQL-only `ifnull(...)` with `coalesce(...)` in the two
  source/second-source percentage subqueries that remain raw (they
  interpolate dynamic table/field names).
- zero_amount_refdocs: raw `sql_list` → `frappe.get_all(pluck="name")`.
- update_billing_status: two raw `ifnull(sum(qty), 0)` selects → frappe.qb
  `Sum`; an empty result yields None and flt(None) == 0, matching the old
  ifnull behaviour.

Behaviour is unchanged on MariaDB. The percentage path (coalesce subquery)
is exercised by test_selling_controller's Sales Order -> Delivery Note
per_delivered test on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:10:19 +05:30
Mihir Kandoi
f793027800 refactor(controllers): convert SellingController delivered-qty lookups to qb/ORM
get_already_delivered_qty used two raw frappe.db.sql sums (Delivery Note
Item, and Sales Invoice Item joined to Sales Invoice) and
get_so_qty_and_warehouse used a raw select. Convert to frappe.qb (Sum) and
frappe.db.get_value. Engine-portable and MariaDB-identical.

Adds a test (Sales Order -> partial Delivery Note) that asserts
per_delivered, exercising get_already_delivered_qty / get_so_qty_and_warehouse
(and the StatusUpdater percentage path) on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:10:17 +05:30
Mihir Kandoi
ce2e7fb7ee refactor(startup): convert boot_session raw SQL to ORM (Postgres-valid)
boot_session used raw `frappe.db.sql`, including a MySQL-only
`ifnull(account_type, '')` over Party Type that is invalid on Postgres.

- customer_count: `SELECT count(*)` → `frappe.db.count`
- setup_complete: `SELECT name ... LIMIT 1` → `frappe.db.get_all(limit=1)`
- companies: raw select → `frappe.get_all`, preserving the `:Company`
  virtual-doc marker
- party_account_types: `ifnull(account_type,'')` → `frappe.get_all` with a
  Python `account_type or ""`, which collapses NULL→'' and ''→''
  identically on both engines (handles Postgres storing '' as NULL)

Adds a test (no test file existed) that runs boot_session and asserts the
company list and party_account_types are populated, on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:03:47 +05:30
Mihir Kandoi
7fe79b115d refactor(stock): convert ItemAttribute.validate_exising_items to query builder
validate_exising_items() used a raw frappe.db.sql implicit-join to find
variant items using the attribute. Convert it to a frappe.qb inner join
(engine-portable, MariaDB-identical) so it no longer relies on raw SQL.

Only this query is converted; develop's update_variant_attribute_values
on_update hook and its imports are left intact (the staging branch's
whole-file version predated and would have reverted them).

Adds a focused test that creates a variant and asserts validate_exising_items
finds it (the validation only raises if the converted query returned the
variant row). Passes on MariaDB and Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:58:14 +05:30
Mihir Kandoi
9fb08153d6 fix(stock): make Available Batch report GROUP BY Postgres-valid
Both get_batchwise_data_from_stock_ledger and
get_batchwise_data_from_serial_batch_bundle select Batch columns
(expiry_date, and item_name when show_item_name is set) while grouping
only by Stock Ledger Entry columns. MariaDB arbitrary-picks the Batch
columns; Postgres rejects the query with "column ... must appear in the
GROUP BY clause".

Add the Batch PK (batch.name) to both GROUP BYs. batch.name is 1:1 with
the grouped batch_no (the join condition), so groups are unchanged and the
result is identical on MariaDB.

The serial-batch-bundle query additionally grouped by ch_table.warehouse
while selecting table.warehouse; group by the selected (SLE) warehouse so
the grouped and selected columns match (also required by Postgres).

Adds a test (no test file existed) that receives batch stock and asserts
the report lists it with the correct balance, exercising the GROUP BY on
both engines (with show_item_name set to force the extra Batch column).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:58:14 +05:30
Mihir Kandoi
52d7f56922 refactor(setup): make Authorization Control Postgres-valid (ifnull→coalesce, raw SQL→qb)
authorization_control.py used MySQL-only `ifnull()` in its raw rule
lookups (invalid on Postgres) and several raw `frappe.db.sql` selects.

- Replace every `ifnull(...)` with the portable `coalesce(...)` in the
  rule-lookup statements that remain raw (they interpolate dynamic
  conditions and rely on Frappe's Postgres backtick translation).
- Convert the user/role based_on lookups in validate_approving_authority
  and the four value-based lookups in get_value_based_rule to frappe.qb
  (Coalesce, isin, and a fresh Employee-designation subquery per use).

Behaviour is unchanged on MariaDB; the queries now run on Postgres.

Adds a test (no test file existed): a not-authorized case that exercises
the based_on + coalesce rule lookups (run as a non-admin user, since
Administrator implicitly holds every role), and a get_value_based_rule
call that exercises all four query-builder lookups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:55:59 +05:30
Mihir Kandoi
e9c391608c fix(manufacturing): make Work Order Stock report GROUP BY Postgres-valid
get_item_list() computes build_qty as IfNull(bin.actual_qty * bom.quantity
/ bom_item.stock_qty, 0) while grouping only by bom_item.item_code. The
three operand columns are neither grouped nor aggregated, so MariaDB
arbitrary-picks them but Postgres rejects the query with "column ... must
appear in the GROUP BY clause".

Add bom.quantity, bom_item.stock_qty and bin.actual_qty to the GROUP BY.
The WHERE pins bom/item and the join pins warehouse to single rows (Bin is
unique per item+warehouse), so the result stays one row per item and
MariaDB behaviour is unchanged.

Adds a test (no test file existed) that runs the report against a Work
Order and asserts it is listed, exercising the query on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:44:02 +05:30
Mihir Kandoi
0b4e52e8d7 fix(buying): make Purchase Order Analysis GROUP BY Postgres-valid
get_data() grouped only by Purchase Order Item while selecting Purchase
Order parent columns. MariaDB allows this loose GROUP BY; Postgres rejects
it with "column ... must appear in the GROUP BY clause".

Add the Purchase Order PK (po.name) to the GROUP BY. po.name is 1:1 with
the already-grouped po_item.name, so groups are unchanged and the result
is identical on MariaDB.

Adds a test (no test file existed) that runs the report and asserts the PO
is listed, exercising the GROUP BY on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:40:35 +05:30
Mihir Kandoi
8b4845d272 fix(buying): make Procurement Tracker GROUP BY Postgres-valid
get_po_entries() grouped only by (Purchase Order, material_request_item)
while selecting other Purchase Order Item columns. MariaDB allows this
loose GROUP BY (arbitrary-picking the extra columns); Postgres rejects it
with "column ... must appear in the GROUP BY clause".

Add the Purchase Order Item PK (child.name) to the GROUP BY so the
selected child columns are functionally determined by a grouped key.

Behaviour note: this is not a MariaDB no-op. When one PO has multiple
items sharing the same/blank material_request_item, MariaDB collapsed
them into one arbitrary row; now there is one row per PO line. The
downstream report already keys rows by purchase_order, so totals are
unaffected and the per-line breakdown is more correct.

Adds a test that runs the report and asserts the PO is listed, exercising
the GROUP BY on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:40:33 +05:30
mergify[bot]
efa4d76c50 Merge pull request #56187 from aerele/fix/job-card-partially-transferred-status
fix: add partially transferred status and fix button visibility for partial material transfer on job card
2026-06-20 19:02:28 +00:00
Shllokkk
f83a80de48 Merge pull request #56155 from aerele/fix/party-type-filter-v16
fix: fetch party types based on account type in journal entry
2026-06-21 00:02:02 +05:30
Mihir Kandoi
4255059846 Merge pull request #56196 from mihir-kandoi/pg-bom-groupby-fix
fix(manufacturing): make get_bom_items_as_dict Postgres-valid (GROUP BY)
2026-06-20 22:33:30 +05:30
Mihir Kandoi
cfedcc06c8 Merge pull request #56197 from mihir-kandoi/pg-stock-entry-groupby-fix
fix(stock): Postgres GROUP-BY validity for Job Card secondary-item & disassembly queries
2026-06-20 22:24:10 +05:30
Mihir Kandoi
79cbefb088 fix(manufacturing): make get_bom_items_as_dict Postgres-valid (GROUP BY)
_query_bom_items / _build_base_bom_items_query / _add_*_item_columns selected
non-grouped columns (idx, item_name, image, project, item-default fields, BOM
Item attributes) alongside `group by item_code` -> arbitrary pick on MariaDB,
GroupingError on Postgres. Wrap them in Max() (Min() for idx, preserving the
original ordering). Every wrapped column is functionally dependent on the
grouped item_code (item attributes / the single BOM's project / one Item
Default per item+company), so Max()/Min() returns exactly the value MySQL
picked arbitrarily -> MariaDB output unchanged.

This was previously shipped in #56008 and reverted with that batch; re-applied
in isolation here. Verified: test_work_order 85/85 on BOTH MariaDB (no change)
and Postgres (was 85/85 failing on this query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:13:29 +05:30
Mihir Kandoi
911a27e8e6 Merge pull request #56195 from mihir-kandoi/pg-stock-reconciliation
refactor(stock): port Stock Reconciliation raw SQL to qb/ORM (Postgres compat)
2026-06-20 22:12:14 +05:30
Mihir Kandoi
810e93758e fix(stock): make disassembly manufacture-entry query Postgres-valid (GROUP BY)
get_items_from_manufacture_stock_entry aggregated Stock Entry Detail rows by
item_code while selecting item_name/description/warehouses/etc. (and an orderby
on the non-grouped idx) -> arbitrary pick on MariaDB, GroupingError on Postgres.
Wrap the non-grouped columns in Max() (Min(idx) for the orderby), preserving the
one-row-per-item shape the disassembly expects; an item plays one role with one
uom/warehouse across the WO's manufacture entries, so MariaDB output is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:04:46 +05:30
Mihir Kandoi
4d29bfbe07 fix(stock): make Job Card secondary-item query Postgres-valid (GROUP BY)
get_secondary_items_from_job_card selected item_name/description/stock_uom/
bom_secondary_item alongside `group by item_code, secondary_item_type` (and an
orderby on the non-grouped idx) -> arbitrary pick on MariaDB, GroupingError on
Postgres. Wrap the non-grouped columns in Max() (Min(idx) for the orderby);
they are item attributes / the secondary-item BOM link, constant per group, so
MariaDB output is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:53:50 +05:30
Mihir Kandoi
554c196870 refactor(stock): convert Stock Reconciliation raw SQL to qb/ORM
validate_expense_account SLE existence check -> frappe.db.get_all(limit=1);
get_items_for_stock_reco's two comma-join SELECTs -> frappe.qb inner_joins, with
the correlated Warehouse-subtree EXISTS replaced by a precomputed
warehouses_in_tree subquery + isin and ifnull(disabled,0)=0 -> disabled==0|isnull.
The Item-Default query's `group by i.name` is dropped (sound: validate_item_defaults
enforces one Item Default per (item, company), so the company-filtered query already
returns one row per item; the downstream (item_code, warehouse) de-dup is unchanged).
Same result on MariaDB; valid under Postgres.

Tests: get_items_for_stock_reco Bin branch (stocked item) and Item-Default branch
(default_warehouse, no stock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:19:36 +05:30
Mihir Kandoi
8c1c8a3cee Merge pull request #56192 from mihir-kandoi/pg-purchase-receipt
refactor(stock): port Purchase Receipt + LCV raw SQL to qb/ORM + #39 GROUP-BY fixes (Postgres)
2026-06-20 20:56:56 +05:30
Mihir Kandoi
b811dba5c2 fix(stock): aggregate non-grouped cols in get_items_to_be_repost (PG #39)
get_items_to_be_repost selected posting_date/posting_time/creation/posting_datetime
alongside `group_by item_code, warehouse` with no aggregation -> arbitrary pick on
MariaDB, GroupingError on Postgres. Wrap the four columns in `Min()` (earliest row
per item+warehouse, the correct repost-start point; a single voucher's SLEs share
posting_date/time per group -> MariaDB-identical). This is reached by every stock
transaction submit/cancel via repost_future_sle_and_gle, so it unblocks the whole
transaction-heavy stock suite on Postgres (e.g. test_purchase_receipt 105/105).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:09:05 +05:30
Mihir Kandoi
afb7c25141 refactor(stock): convert LCV serial-rate update to qb + fix cost_center GROUP BY (PG)
Convert the `update tabSerial No set purchase_rate ... where name in (...)` to
frappe.qb.update(isin). Also fix the #39 Postgres bug in
set_landed_cost_voucher_amount: `.select(Sum(applicable_charges), cost_center)`
selected a non-grouped column with no GROUP BY (GroupingError on PG) -> wrap it
in `Max(cost_center)` (deterministic representative; per (receipt_document,
receipt_item) the matching LCV items share a cost_center -> MariaDB-identical).
Covered by the existing landed-cost tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:09:05 +05:30
Mihir Kandoi
c76c0d85ba refactor(stock): convert PR get_invoiced_qty_map to qb aggregate
Replace the raw `select pr_detail, qty from Purchase Invoice Item` (summed in
Python) with a frappe.qb GROUP BY Sum(qty) per pr_detail, matching the sibling
get_returned_qty_map. Same result on MariaDB; valid under Postgres. Covered by
the existing make_purchase_invoice tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:09:05 +05:30
Mihir Kandoi
a65aa27225 refactor(stock): convert Purchase Receipt raw SQL to ORM
get_already_received_qty (sum over Purchase Receipt Item, parent != self.name)
and the two Purchase-Invoice-against-receipt existence checks (implicit
comma-joins -> child-table get_all on Purchase Invoice Item, docstatus=1).
Also fixes a pre-existing `self.submit_rv` -> `submit_rv` typo in the (dead)
check_next_docstatus that staging carried forward. Same result on MariaDB;
valid under Postgres.

Tests: get_already_received_qty (parent-exclusion sum) and check_next_docstatus
(blocks on a submitted Purchase Invoice; also locks the typo fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:08:43 +05:30
Diptanil Saha
5505ae43d4 Merge pull request #56191 from diptanilsaha/fix/perms_on_whitelisted_functions
fix: added missing permission validation on whitelisted function and removed unnecessary whitelisted decorator
2026-06-20 19:50:28 +05:30
diptanilsaha
e29535f29c fix(report_utils): remove unnecessary whitelist decorator on get_invoiced_item_gross_margin 2026-06-20 19:28:49 +05:30
diptanilsaha
9bf1e847d2 fix(err): add missing permission check on get_account_details 2026-06-20 19:28:49 +05:30
Mihir Kandoi
bfffed0f52 Merge pull request #56186 from mihir-kandoi/pg-stock-valuation-core
refactor(stock): port valuation-core helpers raw SQL to qb/ORM (Postgres compat)
2026-06-20 19:10:11 +05:30
pandiyan
a22b83a97f fix: add partially transferred status and fix button visibility for partial material transfer on job card 2026-06-20 14:08:14 +05:30
Mihir Kandoi
46c1b49be1 refactor(stock): convert backdated-entry SLE check to qb (PG fix)
The authorized-user backdated-entry guard used MariaDB-only
`MAX(timestamp(posting_date, posting_time))`, invalid on Postgres. Convert to
`Max(posting_datetime)` (the precomputed column) via frappe.qb. Same result on
MariaDB; now valid under Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:01:33 +05:30
Mihir Kandoi
aa73606ed2 refactor(stock): convert get_warehouse_account lookup to get_all
Replace the raw nearest-ancestor warehouse-account SELECT with frappe.get_all;
`account is not null and ifnull(account,'')!=''` -> filter ["account","is","set"]
(IS NOT NULL AND != ''), order_by lft desc, limit 1, pluck. Same result on
MariaDB; valid under Postgres. Covered by the existing get_warehouse_account tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:01:33 +05:30
Mihir Kandoi
57ea0ff6aa refactor(stock): convert stock/utils.py raw SQL to qb/ORM
Convert get_stock_value_from_bin (comma-join + internal ifnull/warehouse-subtree
fragments -> inner_join + qb subquery), get_latest_stock_qty, get_latest_stock_balance,
get_avg_purchase_rate and get_incoming_outgoing_rate_for_cancel (Case/Abs) to
frappe.qb / get_all. Same result on MariaDB; valid under Postgres.

Tests: get_latest_stock_qty and get_stock_value_from_bin against received stock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:01:32 +05:30
Mihir Kandoi
17108d8a37 refactor(stock): convert stock_balance raw SQL to qb/ORM
Convert the repost item/warehouse UNION, get_balance_qty_from_sle,
get_reserved_qty (UNION of correlated subqueries -> two qb Sum branches with an
inner_join to Sales Order Item, added in Python; qty!=0 guards the divide and
mirrors the original `qty>=delivered_qty` which on MariaDB excluded x/0 NULL
rows), get_indented_qty, get_planned_qty and set_stock_balance_as_per_serial_no
to frappe.qb / get_all / db.count. Same result on MariaDB; valid under Postgres.

Tests (new test_stock_balance.py): get_reserved_qty SO-item + packed-bundle
branches and get_indented_qty, all without delivery so they avoid the unrelated
#39 SLE-repost path and pass on Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:39:43 +05:30
Mihir Kandoi
85556913d6 Merge pull request #56185 from mihir-kandoi/pg-material-request
refactor(stock): port Material Request raw SQL to qb/ORM (Postgres compat + TIMEDIFF fix)
2026-06-20 09:36:54 +05:30
Mihir Kandoi
4062f72bdb refactor(stock): convert Material Request raw SQL to ORM
validate_qty_against_so: the already-indented (Material Request Item) and
Sales-Order-qty (Sales Order Item) sum lookups -> frappe.get_all({SUM}).
check_modified_date: raw `select modified` + MariaDB-only `TIMEDIFF` ->
frappe.db.get_value + a get_datetime() comparison. The TIMEDIFF removal also
fixes a real Postgres bug: update_status() (Stop/Reopen/Cancel) ran TIMEDIFF,
which errors on PG (`function timediff does not exist`); this greens 7
previously-failing status-change tests on Postgres.

Same result on MariaDB. Tests: concurrent-modification guard (pass + throw
branches) and the over-request-against-SO throw (both converted SUM queries +
the boundary). mapper.py is intentionally left untouched (no raw SQL; its
staging copy predates develop's RFQ cost_center field-map).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:17:07 +05:30
Mihir Kandoi
4479d7ff18 Merge pull request #56181 from mihir-kandoi/pg-delivery-note
refactor(stock): port Delivery Note & Delivery Trip raw SQL to qb/ORM (Postgres compat)
2026-06-20 00:21:37 +05:30
Mihir Kandoi
5b8ba4bd52 Merge pull request #56179 from mihir-kandoi/pg-stock-masters
refactor(stock): port masters/settings/dashboards raw SQL to qb/ORM (Postgres compat)
2026-06-20 00:21:25 +05:30
Mihir Kandoi
7f81ffca23 refactor(stock): convert Delivery Trip contact/address lookups to qb
get_default_contact / get_default_address: raw correlated-subquery SELECTs over
Dynamic Link -> frappe.qb with a LEFT join (preserving the original
correlated-subquery semantics: a Dynamic Link whose parent Contact/Address is
missing still returns, with a NULL flag). Same result on MariaDB; valid under
Postgres.

Tests: pin the converted query output (real linked Contact/Address) and lock
the LEFT-join choice with an orphaned-Dynamic-Link case (fails under an inner
join).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:01:05 +05:30
Mihir Kandoi
28f6994520 refactor(stock): convert DN billed-amount SUM to get_all
update_billed_amount_based_on_so: raw "select sum(amount) ... where
dn_detail=%s and docstatus=1" -> frappe.get_all(fields=[{SUM: amount}]); the
bare aggregate needs no GROUP BY and the NULL-sum still resolves to 0. Same
result on MariaDB; valid under Postgres. Covered by the existing billing tests
in test_delivery_note.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:01:05 +05:30
Mihir Kandoi
6c96606c18 refactor(stock): convert packing-slip cancellation lookup to get_all
cancel_packing_slips: raw "SELECT name FROM `tabPacking Slip` WHERE
delivery_note=%s AND docstatus=1" -> frappe.get_all(pluck="name") with
pluck-aware iteration. Same result on MariaDB; valid under Postgres.

Covered by test_cancel_packing_slips_cancels_submitted_slips in
test_delivery_note.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:01:05 +05:30
Mihir Kandoi
ff4adce91b refactor(stock): convert Delivery Note raw SQL to ORM
set_actual_qty Bin lookup -> frappe.db.get_value; validate_proj_cust raw
"customer=%s OR ifnull(customer,'')=''" -> get_all or_filters with
[customer, is, not set] (correct PG empty-string/NULL handling); the two
check_next_docstatus implicit comma-joins -> get_all on the child table
(Sales Invoice Item / Installation Note Item, docstatus=1). Same result on
MariaDB; valid under Postgres.

Tests: validate_proj_cust mismatch + no-customer (the or_filters branch), and
check_next_docstatus blocking cancel when a submitted Sales Invoice draws from
the DN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:01:05 +05:30
Mihir Kandoi
48bbf66422 refactor(stock): convert packing slip item search to qb.get_query
Replace the raw SELECT with get_match_cond in item_details with
frappe.qb.get_query(ignore_permissions=False) plus a Delivery Note Item
subquery; get_query applies the permission match conditions. Same result on
MariaDB; valid under Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:58 +05:30
Mihir Kandoi
f9732efb23 refactor(stock): convert stock settings checks to ORM/qb
Replace the get_all(limit=1, pluck) existence checks with frappe.db.exists and
the no-own-valuation-method Stock Ledger Entry EXISTS with a frappe.qb
subquery (null-or-empty valuation_method preserved). Same result on MariaDB;
valid under Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:58 +05:30
Mihir Kandoi
3e801a2067 refactor(stock): convert serial_no lookups to ORM
Replace the on_trash Stock Ledger Entry serial-match SELECT and the
update_maintenance_status expiry SELECT with frappe.get_all (or_filters for
the amc/warranty expiry OR). Same result on MariaDB; valid under Postgres.

Tests: maintenance-status expiry transition, the not-in exclusion (with a
negative-control candidate), and NULL-status handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:58 +05:30
Mihir Kandoi
d61720c3e2 refactor(stock): convert Job Card reference update to qb
Replace the raw f-string UPDATE (name + production_item match) that links a
Quality Inspection back to its Job Card with frappe.qb.update. Same result on
MariaDB; valid under Postgres.

Tests: the Job Card reference update and its production_item scoping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
d955122c88 refactor(stock): convert Item Price bulk update to qb
Replace the raw UPDATE ... modified=NOW() in update_item_price with
frappe.qb.update (now() for modified). Same result on MariaDB; valid under
Postgres.

Tests: currency/buying/selling/modified propagation and price-list scoping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
b0d9208561 refactor(stock): convert get_alternative_items UNION to ORM
Replace the raw UNION of forward/two-way alternative-item matches with two
frappe.get_all calls, order-preserving dedup (dict.fromkeys) and Python
pagination. Each leg is bounded to start+page_len rows so the per-keystroke
search round trip stays small (the original bounded with LIMIT/OFFSET);
ItemAlternative forbids duplicate (item_code, alternative_item_code) pairs, so
each leg is internally distinct and that bound is exact. Same result on
MariaDB; valid under Postgres.

Tests: both-direction dedup, txt filtering, pagination, bounded-and-exact
page-walk reconstruction, and case-insensitive (ILIKE-on-Postgres) matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
f03a81b943 refactor(stock): use get_all for warehouse subtree in capacity dashboard
Replace the raw lft/rgt SELECT with frappe.get_all(pluck="name"). Same result
on MariaDB; valid under Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
497a0abb07 refactor(stock): build item-group filter via qb in item_dashboard
Replace the raw EXISTS subquery (items within an item-group subtree) with
frappe.qb (item_group.isin(subquery)). Same result on MariaDB; valid under
Postgres' stricter SQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
884f57d5f6 Merge pull request #56182 from mihir-kandoi/pg-fix-timesheet-summary-flaky
test(projects): fix time-of-day flaky daily-timesheet-summary test
2026-06-20 00:00:17 +05:30
Mihir Kandoi
9dcd561778 test(projects): fix time-of-day flaky daily-timesheet-summary test
make_timesheet(simulate=True) logs at now_datetime(); when the suite runs late
in the day under the site timezone the 2h log crosses midnight, so its to_time
falls outside the report's `to_time <= end-of-day` bound and the submitted
timesheet is (correctly) excluded — making test_submitted_timesheet_in_summary
fail in an evening window (observed at 22:51 IST in CI). Pin the log to a fixed
mid-day window on today so the assertion is time-of-day independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:29:45 +05:30
Nabin Hait
0bcafa1fde fix: use full refresh instead of refresh_fields for multi currency
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-19 23:16:27 +05:30
Mihir Kandoi
a5f21331a4 Merge pull request #56178 from mihir-kandoi/pg-misc
refactor(postgres): port Telephony/Quality/Bulk-Transaction/Utilities/Portal queries to the query builder
2026-06-19 21:41:37 +05:30
Mihir Kandoi
be21f56771 refactor(postgres): port payment_setup_certification query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:13 +05:30
Mihir Kandoi
fbcec6e75f refactor(postgres): port rename_tool get_doctypes to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:11 +05:30
Mihir Kandoi
5fcaa54f04 refactor(postgres): port bulk_transaction_log existence check to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:10 +05:30
Mihir Kandoi
c18ca7af22 refactor(postgres): port quality_procedure on_trash to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:08 +05:30
Mihir Kandoi
1cfae33fb0 refactor(postgres): port transaction_base delete_events to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:06 +05:30
Mihir Kandoi
5548c3a713 refactor(postgres): port support index favorite-articles query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:03 +05:30
Mihir Kandoi
928bbf22d2 refactor(postgres): port call_log link_existing_conversations to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:10:00 +05:30
Mihir Kandoi
57e44b3a5f Merge pull request #56173 from mihir-kandoi/pg-manufacturing-maintenance
refactor(postgres): port Manufacturing & Maintenance module queries to the query builder
2026-06-19 18:57:06 +05:30
mergify[bot]
a01da137ba Merge pull request #56066 from frappe/l10n_develop
fix: sync translations from crowdin
2026-06-19 13:24:25 +00:00
Mihir Kandoi
09f03e34d0 refactor(postgres): port maintenance_visit queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:23:08 +05:30
Mihir Kandoi
afbaaafd00 refactor(postgres): port maintenance_schedule queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:23:08 +05:30
Mihir Kandoi
71a07ee7af refactor(postgres): port workstation queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:23:08 +05:30
Mihir Kandoi
8134199a57 refactor(postgres): port work_order make_bom and stock-entry check to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:23:08 +05:30
rohitwaghchaure
8479a8b4d3 Merge pull request #56102 from rohitwaghchaure/feat-allocate-full-amount-to-stock-items
feat: allocate full actual charge to stock items only (e.g. Freight)
2026-06-19 18:05:18 +05:30
Mihir Kandoi
9a612d0164 Merge pull request #56153 from mihir-kandoi/pg-selling
refactor(postgres): port Selling module queries to the query builder
2026-06-19 17:09:29 +05:30
rohitwaghchaure
3a6b32bcf9 Merge pull request #56032 from rohitwaghchaure/add_serial_no_composite_index_develop
perf: composite index on (serial_no, warehouse, posting_datetime) for Serial and Batch Entry
2026-06-19 17:00:50 +05:30
rohitwaghchaure
81dea34dd3 Merge pull request #56077 from aerele/fix/support-#69720
fix(stock): apply precision to the additional cost amount in stock entry
2026-06-19 16:52:04 +05:30
Nabin Hait
be05e01bd7 Merge pull request #56151 from nabinhait/refactor-si-mapper
refactor(sales_invoice): shrink make_inter_company_transaction mapper
2026-06-19 16:47:56 +05:30
Nabin Hait
61927b61fe Merge pull request #56139 from nabinhait/refactor-si-timesheet-billing
refactor(sales_invoice): simplify TimesheetBillingService link decision
2026-06-19 16:47:16 +05:30
Nabin Hait
b8e699b226 refactor(sales_invoice): simplify fixed-asset and inter-company validations
validate_fixed_asset (C/11) is flattened with a guard clause and the per-item
checks move into _validate_fixed_asset_item. validate_inter_company_party
(C/12) splits into _get_inter_company_party_config plus _validate_against_reference
and _validate_internal_party_company (conditions preserved verbatim). No C-rank
function remains in either module.

Characterize the previously-untested asset-sale throws (missing asset, update
stock, return without return-against, selling a sold/scrapped asset) and the
asset-restore note text before the move; behaviour is unchanged (asset and
inter-company suites green).
2026-06-19 16:41:39 +05:30
ruthra kumar
f8550838a3 Merge pull request #55265 from aerele/bank-guarantee-type
fix: update reference doctype mapping and field visibility in bank guarantee
2026-06-19 16:38:53 +05:30
Rohit Waghchaure
9e15e52847 feat: allocate full actual charge to stock items only (e.g. Freight)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:35:45 +05:30
Nabin Hait
f24ea74ef8 refactor: simplify Journal Entry client script
- Replace the JournalEntry controller class and cur_frm.cscript free
  functions with frappe.ui.form.on event blocks plus a namespaced
  erpnext.journal_entry helper object
- Drop deprecated APIs: cur_frm/script_manager, add_fetch, $.each and var
- Move the bank_account -> account fetch to fetch_from on the
  Journal Entry Account "account" field
- Keep totals/difference and company-currency conversion on the client
  (cheap, race-free); call the server only to fetch exchange rates
- get_balance now computes its own difference instead of trusting the
  client-sent value, with a regression test
2026-06-19 16:34:31 +05:30
Mihir Kandoi
a954539b53 refactor(postgres): port sales_analytics tree/order-type queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:28:23 +05:30
Mihir Kandoi
f8120d1818 refactor(postgres): port point_of_sale get_items to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:28:21 +05:30
Nabin Hait
d5d2e3406b Merge pull request #56144 from nabinhait/refactor-si-status
refactor(sales_invoice): simplify StatusService.set_status, cover set_indicator
2026-06-19 16:17:11 +05:30
Mihir Kandoi
a80be19081 refactor(postgres): port sales_funnel funnel counts to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:34 +05:30
Mihir Kandoi
9ce1b02e6e refactor(postgres): rebuild available_stock_for_packing_items report without raw SQL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:32 +05:30
Mihir Kandoi
f4d9869d7b refactor(postgres): port customer_acquisition_and_loyalty report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:31 +05:30
Mihir Kandoi
6b1e339ed4 refactor(postgres): port customer_credit_balance report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:29 +05:30
Mihir Kandoi
fe13c0709b refactor(postgres): port pending_so_items_for_purchase_request report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:28 +05:30
Mihir Kandoi
c86aa3e3ad refactor(postgres): port sales_order_analysis report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:26 +05:30
Mihir Kandoi
60e05bdaa6 refactor(postgres): port quotation.set_expired_status off multisql to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:25 +05:30
Nabin Hait
4f42f52306 Merge pull request #56147 from nabinhait/refactor-si-gl-helper-names
refactor(sales_invoice): verb-prefix SalesInvoiceGLComposer helper names
2026-06-19 16:12:30 +05:30
Nabin Hait
e85f2c4fbc refactor(sales_invoice): shrink make_inter_company_transaction mapper
The function was 199 lines, dominated by a 102-line update_details closure.
Extract the two party-mapping branches into module helpers
_apply_purchase_party_details / _apply_sales_party_details and the address
lookup into _get_linked_address; update_details is now a 6-line dispatcher.
make_inter_company_transaction drops to ~104 lines. No behaviour change
(inter-company SI->PI and PO->SO suites green).
2026-06-19 16:09:49 +05:30
Mihir Kandoi
bbc684aa80 Merge pull request #56142 from mihir-kandoi/pg-projects
refactor(postgres): port Projects module queries to the query builder
2026-06-19 15:54:52 +05:30
Nabin Hait
cb97c3a55a refactor(sales_invoice): verb-prefix SalesInvoiceGLComposer helper names
Rename three private helpers to follow the verb-prefixed convention used
across the services:
- _amount_in_account_currency -> _get_amount_in_account_currency
- _return_aware_against_voucher -> _resolve_against_voucher
- _sdbnb_booking_for_item -> _get_sdbnb_booking_for_item

Pure rename of private methods, no behaviour change.
2026-06-19 15:49:17 +05:30
Nabin Hait
cb6fc640ce refactor(sales_invoice): simplify StatusService.set_status and cover set_indicator
set_status was a single status-resolution cascade (cyclomatic complexity
C/19). Extract the submitted-invoice resolution into _submitted_status (with
the invoice-discounting suffix) and _payment_status (guard clauses), leaving
the cancelled/draft quirks inline. set_status drops C/19 -> B/7; no C-rank
function remains in the module.

Add a characterization test for set_indicator, which was 93% untested -
the portal indicator colour/title for credit-note / unpaid / overdue /
return / paid states. Behaviour is unchanged (status and invoice-discounting
suites green).
2026-06-19 15:44:55 +05:30
Nabin Hait
3d44b4d98c refactor(sales_invoice): simplify TimesheetBillingService._update_time_sheet_detail
The link-decision was a single four-way boolean OR (cyclomatic complexity
C/16) where every branch repeated 'args.timesheet_detail == data.name'.
Factor that match out as a loop guard and extract the remaining decision into
_should_set_sales_invoice as ordered guard clauses. Behaviour is unchanged
(project, link-on-submit, unlink-on-cancel and return paths preserved);
characterization tests and the full timesheet suite are green.
2026-06-19 15:31:31 +05:30
Nabin Hait
dd7891e18f test(timesheet): characterize sales-invoice link/unlink and submit guard
Pin TimesheetBillingService behaviour before refactor: billing a timesheet
into a Sales Invoice links the timesheet detail and marks it Billed on submit,
and clears the link / reverts status on cancel; an unsubmitted timesheet
cannot be invoiced.
2026-06-19 15:31:31 +05:30
Mihir Kandoi
ea665d1a9b refactor(postgres): port Projects module queries to the query builder
Convert raw `frappe.db.sql` across the Projects module to `frappe.qb` / the ORM
so the same code runs on MariaDB and Postgres. Behaviour is preserved on
MariaDB; the conversions also make these paths valid under Postgres' stricter
SQL (GROUP BY, case-sensitivity, empty-string handling).

Conversions of note (behaviour kept identical to the MariaDB original):
- project.get_users_for_project: search selects the stored full_name instead of
  concat_ws(first, middle, last) (concat_ws diverges on Postgres, where empty
  Data fields are NULL) and wraps Locate in LOWER() to keep MariaDB's
  case-insensitive result ordering.
- project costing: percent-complete and sales/billed-amount aggregates rebuilt
  as Sum() query-builder selects.
- task.reschedule_dependent_tasks: the correlated subquery is split into a
  `Task Depends On` parent-pluck + a Task lookup (same rows, no nested SQL).
- timesheet.get_events: user-permission match conditions move to the query-builder
  form via get_event_conditions_qb; calendar columns rebuilt with Concat/Round.
- report/project_wise_stock_tracking & report/daily_timesheet_summary: GROUP BY
  cost aggregates and the timesheet date window (timestamp(to_date,'24:00:00') ->
  end-of-day via get_combine_datetime) rebuilt to satisfy Postgres.
- search helpers (query_task, get_project, get_timesheet) use frappe.qb.get_query
  with ignore_permissions=False in place of build_match_conditions/get_match_cond.

Tests (run on both MariaDB and Postgres, --lightmode):
- Existing project/task/timesheet/activity_cost suites kept green (27 tests).
- New project_wise_stock_tracking test drives all three cost aggregates with
  positive data (purchased / issued / delivered GROUP BY) plus get_project_details.
- New daily_timesheet_summary test covers the date-window join.

Not included: project_update.py is deferred. Its daily_reminder()/email_sending()
select `progress`/`progress_details`, columns that do not exist on the Project
Update doctype, so the function errors when invoked regardless of backend - a
pre-existing bug that needs an email-rework, not just a query port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:22:20 +05:30
Nabin Hait
6255495cc4 Merge pull request #55897 from nabinhait/fix/project-sales-order-link-overwrite
fix(projects): don't overwrite existing Sales Order project link
2026-06-19 15:15:35 +05:30
Diptanil Saha
8c1a1aafe6 fix(coa_importer): allow importing COA through import_coa only for Accounts Manager (#56132)
* fix(coa_importer): allow importing COA only for `Accounts Manager`

Co-authored-by: Pratheep S <pratheeps2024@gmail.com>

* fix(coa_importer): check permissions in `unset_existing_data`

---------

Co-authored-by: Pratheep S <pratheeps2024@gmail.com>
2026-06-19 15:08:20 +05:30
Mihir Kandoi
0a9aa448c1 Merge pull request #56134 from mihir-kandoi/pg-setup-utilities-templates-regional
refactor(postgres): port Setup/Utilities/Templates/Regional queries to the query builder
2026-06-19 15:02:56 +05:30
Mihir Kandoi
02f7cba20a Merge pull request #55920 from ljain112/fix-scio-customer-material-avg-rate
fix: update weighted average rate calculation to consider returned and consumed quantities
2026-06-19 14:47:16 +05:30
Mihir Kandoi
96d4c48357 refactor(postgres): port Setup/Utilities/Templates/Regional queries to the query builder
Convert raw `frappe.db.sql` in the Setup, Utilities, Templates and Regional
areas to `frappe.qb` / the ORM so the same code runs on MariaDB and Postgres.
Behaviour is preserved on MariaDB; the conversions also make these paths valid
under Postgres' stricter SQL (GROUP BY, case-sensitivity, reserved words).

Conversions of note (behaviour kept identical to the MariaDB original):
- email_digest: ToDo ordering replicated with a CASE that mirrors MySQL
  `field(priority,'High','Medium','Low')` (unknown/NULL -> 0, sorts first),
  NULL-date-first and a `name` tie-break for a deterministic LIMIT.
- company.get_all_transactions_annual_history: the cross-DocType UNION + GROUP BY
  is replaced by one grouped query per DocType merged with a Counter, so two
  different DocTypes sharing a transaction_date still collapse into one bucket.
- templates/utils.send_message: contact lookup wraps both sides in LOWER() to
  keep MariaDB's case-insensitive email match on case-sensitive Postgres.
- regional/irs_1099 & uae_vat_201: address ranking and emirate aggregation
  rebuilt with CASE/aggregate selects that satisfy Postgres GROUP BY, with a
  deterministic tie-break on the LIMIT-1 address lookups.
- utilities/product.get_item_codes_by_attributes: numeric attribute values are
  cast with cstr() so Postgres doesn't reject `varchar = numeric`.

Tests (run on both MariaDB and Postgres, --lightmode):
- New: company merge test, authorization_rule duplicate-check, youtube report,
  templates/utils, and utilities/templates page reports (partners, rfq,
  material_request_info, product, utilities __init__).
- Existing suites kept green: company, email_digest, transaction_deletion_record,
  irs_1099, uae_vat_201.

Deferred (tracked separately):
- setup/doctype/authorization_control.py still has raw `.format()` SELECTs;
  left for its own PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:37:50 +05:30
Nabin Hait
db2e2105ab fix: Use get_value instead of get_doc
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-19 14:37:32 +05:30
Nabin Hait
e2fbc48b9a Merge pull request #55898 from nabinhait/fix/stock-closing-entry-wrong-field
fix(stock): use correct field when reading previous stock closing balance
2026-06-19 14:36:21 +05:30
Mohd Haris
adfef48a65 fix: allow rename for Quality Inspection Parameter
The Quality Inspection Parameter DocType did not have `allow_rename`
enabled, so the "Rename" action was hidden from the form's menu
(the 3-dots / ⋮ options). Since the DocType is auto-named from the
`parameter` field (`autoname: field:parameter`), users had no way to
correct or change a parameter's name once created.

Enable `allow_rename` so users can rename a Quality Inspection
Parameter from the form menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:29:43 +05:30
Henil
e21c946f14 fix: update modified timestamp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:19:41 +05:30
Henil
a0394fc00c fix: update modified timestamp in Bank Statement Import JSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:13:13 +05:30
Mihir Kandoi
4180e29af4 Merge pull request #56130 from mihir-kandoi/pg-support
refactor(postgres): port Support module queries to the query builder
2026-06-19 13:52:10 +05:30
Mihir Kandoi
39eb34f333 refactor(postgres): address Greptile review on warranty_claim on_cancel
- Filter the parent Maintenance Visit's docstatus (mv.docstatus != 2) via a qb join, as
  the original SQL did, instead of the child Maintenance Visit Purpose row's docstatus.
  Synced in normal flows, but exactly faithful to the original intent.
- Add a limit(500) to bound the read on a cancellation path.

Adds two both-engine tests calling on_cancel directly: an active (non-cancelled) visit
blocks the claim cancel; with no referencing visit the claim is marked Cancelled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:13:14 +05:30
Mihir Kandoi
c6f9415e9d Merge pull request #56129 from mihir-kandoi/fix-test-workstation-warehouse
fix(tests): point _Test Workstation 1 fixture at an existing warehouse
2026-06-19 13:06:48 +05:30
S Sakthivel Murugan
6f225920d0 fix: fetch party types based on account type in journal entry and refactor SQL to query builder 2026-06-19 13:05:35 +05:30
Mihir Kandoi
f768778d81 refactor(postgres): port Support module queries to the query builder
Convert the remaining raw frappe.db.sql in the Support module to frappe.qb / ORM so the
queries run on PostgreSQL as well as MariaDB. Faithful conversions -- no MariaDB
behaviour change:

- issue.py, warranty_claim.py (Maintenance Visit lookup / make_maintenance_visit)
- reports: first_response_time_for_issues and issue_summary (GROUP BY on the grouped
  Date(creation)/based-on field + Avg/Count -- Postgres-valid), support_hour_distribution

Tests: existing issue suite (35) passes on both engines; adds both-engine tests for the
previously-untested warranty_claim mapper (3) and the three reports. All green on
MariaDB and PostgreSQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:58:52 +05:30
Mihir Kandoi
c541bc9239 fix(tests): point _Test Workstation 1 fixture at an existing warehouse
BootStrapTestData.make_workstation() created _Test Workstation 1 referencing the
warehouse "_Test warehouse - _TC" (lowercase 'w'), but make_warehouse() never
creates that name -- it makes "_Test Warehouse - _TC" (capital W) and others.

On MariaDB the lowercase reference resolves to the capital warehouse via the default
case-insensitive collation, so it silently works. On PostgreSQL (case-sensitive) the
link cannot be found, so on a freshly-bootstrapped site make_workstation() raises
LinkValidationError: Could not find Warehouse: _Test warehouse - _TC, which aborts the
module-level BootStrapTestData() import and blocks every test extending ERPNextTestSuite.
It was masked only on sites where the workstation was already bootstrapped (make_records
skips existing) or where the lowercase name happened to exist from legacy data.

Point the fixture at the existing "_Test Warehouse - _TC". Verified on a freshly-reset
site: the buggy fixture raises the LinkValidationError on Postgres and passes after the
fix; MariaDB passes either way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:47:19 +05:30
Nabin Hait
817c5007d9 Merge pull request #55899 from nabinhait/fix/margin-currency-rate-refresh
fix: don't convert item margin on exchange rate refresh
2026-06-19 12:42:21 +05:30
Nabin Hait
900c71840c test(projects): set company on second project to fix CI mandatory error
The second Project in test_sales_order_link_is_not_overwritten_by_second_project
was inserted without a company, which only succeeds when a default company is
configured. On a fresh CI site this raised MandatoryError. Set company from the
sales order explicitly.
2026-06-19 12:37:43 +05:30
Nabin Hait
dfd0c85ba4 Merge pull request #56083 from nabinhait/refactor-si-pos-service
refactor(sales_invoice): tidy POSService (set_pos_fields + mode-of-payment queries)
2026-06-19 12:26:19 +05:30
Henil
37540d90bf fix: correct typo Fromat -> Format in Bank Statement Import label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:03:49 +05:30
Nabin Hait
8caaac96b6 Merge pull request #56086 from nabinhait/refactor-si-gl-composer
refactor(sales_invoice): simplify SalesInvoiceGLComposer GL builders
2026-06-19 12:03:21 +05:30
Nabin Hait
9f02c47592 refactor(sales_invoice): decompose POSService.set_pos_fields and dedupe MOP queries
set_pos_fields was a 97-line method (cyclomatic complexity E/36). Split
it into a small orchestrator plus focused helpers, each preserving the
exact for_validate semantics (A/5 after).

Collapse the three near-identical mode-of-payment query builders onto a
shared _enabled_mode_of_payment_query, and add type hints and docstrings.
Public signatures and return shapes are unchanged.
2026-06-19 11:58:43 +05:30
Nabin Hait
7f47c218ce test(sales_invoice): characterize POSService default and mode-of-payment logic
Pin the behaviour of POSService.set_pos_fields (POS-profile default
resolution and the for_validate guard) and the mode-of-payment query
helpers before refactoring them.
2026-06-19 11:57:40 +05:30
Nabin Hait
ae11b3b848 Merge pull request #56085 from nabinhait/fix-pos-get-warehouse
fix(sales_invoice): remove dead, non-functional POSService.get_warehouse
2026-06-19 11:52:41 +05:30
Mihir Kandoi
64e177df8b Merge pull request #56125 from mihir-kandoi/pg-crm 2026-06-19 10:44:48 +05:30
Mihir Kandoi
413ec60a3e refactor(postgres): address Greptile review on prospects report
- Fix N+1: the per-lead conversion issued 4 queries per lead (Opportunity, Quotation,
  Issue, Communication). Collect the reference documents for all leads in 3 bulk
  queries, then one Communication query per lead -> ~N+3 round-trips instead of 4N,
  matching the original single-query-per-lead cost.
- Constrain reference_doctype in the Communication lookup (names are unique only within
  a doctype), closing a latent cross-doctype name-collision gap the original also had.

Both-engine test still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:26:45 +05:30
Mihir Kandoi
6733681e93 Merge pull request #56124 from mihir-kandoi/pg-assets 2026-06-19 10:22:21 +05:30
Mihir Kandoi
5104007d12 refactor(postgres): port CRM module queries to the query builder
Convert the remaining raw frappe.db.sql in the CRM module to frappe.qb / ORM so the
queries run on PostgreSQL as well as MariaDB. Faithful conversions -- no MariaDB
behaviour change:

- opportunity.py, doctype/utils.py (get_last_interaction)
- reports: campaign_efficiency, first_response_time_for_opportunity (GROUP BY on the
  grouped Date(creation) + Avg -- Postgres-valid), lead_conversion_time,
  prospects_engaged_but_not_converted

lead_conversion_time also keeps the IS NOT NULL communication-date guard (forward-port
of the fix already on develop). Also drops invalid backtick notation from two get_all
order_by clauses in doctype/utils.py (order_by="`creation` DESC"), which frappe's
query engine rejects -- a latent failure on both engines, surfaced by the new test.

Tests: existing opportunity suite plus new both-engine tests for the four previously
untested reports/utils. All green on MariaDB and PostgreSQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:15:20 +05:30
Mihir Kandoi
4bc3420b21 refactor(postgres): address Greptile review on Assets conversions
- cancel_movement_entries: filter the parent Asset Movement's docstatus via a qb join
  (as the original SQL did), instead of the child Asset Movement Item.docstatus. Behaviour
  is identical in normal flows (child docstatus is synced) but this is exactly faithful.
- get_maintenance_log: add a both-engine test for this previously-untested whitelisted
  endpoint. Confirms the frappe v16 dict aggregate field spec ({"COUNT": ...}) runs and
  returns correct per-status counts (no runtime crash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:02:59 +05:30
Mihir Kandoi
fc9608d14d refactor(postgres): port Assets module queries to the query builder
Convert the remaining raw frappe.db.sql in the Assets module to frappe.qb / ORM so
the queries run on PostgreSQL as well as MariaDB. Faithful 1:1 conversions -- no
MariaDB behaviour change:

- asset.py (gl-entry / bom-cost fetches), asset_maintenance.py (team members),
  asset_movement.py (latest location/custodian), location.py (get_children)
- fixed_asset_register.py: the depreciation-amount aggregate groups by asset.name
  (the primary key) selecting only asset.name + Sum(gle.debit), which is valid under
  Postgres strict GROUP BY (PK functional dependency)

Tests: existing asset (61), asset_maintenance, asset_movement and location suites
pass on both engines; adds a test for the previously-untested Fixed Asset Register
report (covers the GROUP BY aggregate on both engines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:48:35 +05:30
Mihir Kandoi
facb27c3f4 Merge pull request #56089 from mihir-kandoi/pg-ar-future-payments
fix(accounts): Journal Entry future payments mis-allocated to one invoice in Accounts Receivable
2026-06-19 08:50:35 +05:30
Smit Vora
68a1fe1480 Merge pull request #56104 from frappe/fix-inclusive-payment-entry-none-base-tax-amount
fix: base_tax_amount as none when payment entry created using API
2026-06-19 08:47:18 +05:30
Mihir Kandoi
e34a64ecee Merge pull request #56118 from frappe/mergify/bp/develop/pr-56065
fix(stock): propagate renamed attribute values to variant items (backport #56065)
2026-06-19 07:42:50 +05:30
MochaMind
060cd9f320 fix: Bosnian translations 2026-06-18 23:55:57 +05:30
MochaMind
eb6530208b fix: Croatian translations 2026-06-18 23:55:52 +05:30
Mihir Kandoi
98e012095a Merge pull request #56113 from mihir-kandoi/pg-accounts-sales-payment-summary
fix(postgres): port sales_payment_summary to qb and make POS data Postgres-valid
2026-06-18 23:03:47 +05:30
Mihir Kandoi
e8bebba915 Merge pull request #56112 from mihir-kandoi/pg-accounts-budget
fix(postgres): port budget queries to qb and fix requested-amount aggregation
2026-06-18 23:01:01 +05:30
Mihir Kandoi
d3c0d9b283 fix: resolve item attribute backport conflict 2026-06-18 22:38:23 +05:30
Mihir Kandoi
1cfb41e1c4 Merge pull request #56111 from mihir-kandoi/pg-accounts-doctypes
refactor(postgres): port accounts doctypes & match-condition helper to qb
2026-06-18 22:35:44 +05:30
barredterra
0e244dd83a fix(stock): update variant attributes on value rename
(cherry picked from commit c7acd88742)
2026-06-18 17:05:40 +00:00
barredterra
4c29d5630d test(stock): add cleanup for item attribute value changes in tests
(cherry picked from commit 60f5de7ab8)
2026-06-18 17:05:40 +00:00
barredterra
4806b82add fix(stock): propagate renamed attribute values to variant items
(cherry picked from commit 27d574dad5)

# Conflicts:
#	erpnext/stock/doctype/item_attribute/item_attribute.py
2026-06-18 17:05:40 +00:00
Mihir Kandoi
5a80278d1e Merge pull request #56098 from aerele/fix/serial-no-work-order-docstatus-filter
fix: apply docstatus filter to exclude cancelled Work Orders in Seria…
2026-06-18 22:25:56 +05:30
Mihir Kandoi
c4e1fe274b Merge pull request #56055 from aerele/fix/support-71415
fix: disable is_debit_note while creating credit note
2026-06-18 22:23:39 +05:30
Mihir Kandoi
1a56f3b032 Merge pull request #56105 from mihir-kandoi/pg-parity-other-class
fix(postgres): MariaDB/Postgres parity in pick-list, serial match, null ordering & link-query ranking
2026-06-18 22:22:16 +05:30
Mihir Kandoi
08375a9e2f Merge pull request #56110 from mihir-kandoi/pg-accounts-gl-core
refactor(postgres): port GL & ledger-core queries to the query builder
2026-06-18 22:02:05 +05:30
Mihir Kandoi
fa378e2d7a test(sales_payment_summary): exercise the POS customer filter
Address review (#56113): add a customer filter to the is_pos test so the
customer-subquery fix is covered (a.customer was unselected before and errored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:34:37 +05:30
Mihir Kandoi
006a65e873 refactor(postgres): finish budget qb conversion (validate_expense_against_budget)
Address review / semgrep (#56112): convert the last raw f-string query
(budget_records, with a dynamic dimension column + tree EXISTS condition) to
frappe.qb, clearing the sql-format-injection finding. Also switch the two
remaining implicit comma-joins (get_requested_amount / get_ordered_amount) to
explicit .join().on() for consistency. test_budget 23/23 on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:32:46 +05:30
Mihir Kandoi
e7b135b51e fix(postgres): aggregate bare account in pos_closing payments; explicit limit on gl-entry fetch
Address review (#56111):
- pos_closing get_payments grouped by mode_of_payment but selected a bare account ->
  Postgres GroupingError. Wrap in Max() (deterministic, both engines agree; account is
  consumed downstream for the change-amount adjustment). test_pos_closing_entry 9/9 both engines.
- get_voucherwise_gl_entries: add limit=0 to make the unbounded fetch explicit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:29:00 +05:30
Mihir Kandoi
dabc94ed06 Merge pull request #56101 from mihir-kandoi/pg-arbitrary-representative
fix(selling): split multi-order invoice amount across its sales orders (payment terms status)
2026-06-18 21:06:33 +05:30
Mihir Kandoi
1f4702bde7 fix(crm): skip rows with no first-contact date in lead conversion time
Address review (#56105): when there's no matching communication, first_contact is
None and date_diff(invoice_date, None) treats None as today, giving a wrong
(negative) duration. Skip the entry instead, mirroring the communication_count guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:04:33 +05:30
Mihir Kandoi
4708ac4e3d fix(postgres): port sales_payment_summary to qb and make POS data Postgres-valid
Converts the report's raw f-string SQL (incl. 3-way UNIONs) to frappe.qb. Split out of #56082.

The non-POS path is MariaDB-identical. get_pos_invoice_data had a loose GROUP BY that
errors on Postgres; line-level warehouse/cost_center/mode_of_payment are now Max()
(deterministic, both engines agree), the unused item_code dropped, and customer added to
the invoice subquery so the customer filter works (it referenced a column the subquery
never selected). + a test covering the previously-untested is_pos path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:35 +05:30
Mihir Kandoi
996a02180b fix(postgres): port budget queries to qb and fix requested-amount aggregation
Converts budget.py raw SQL to frappe.qb for Postgres compatibility. Split out of #56082.

Mostly MariaDB-identical, with one behaviour change: get_requested_amount summed
Sum(qty) * <arbitrary rate> (a bare rate column, invalid on Postgres and wrong when an
item was requested at different rates). Now Sum(qty * rate) per line -- correct, and
identical on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:33 +05:30
Mihir Kandoi
d8a2f53a29 refactor(postgres): port accounts doctypes & match-condition helper to the query builder
Pure MariaDB-identical conversion for Postgres compatibility. Split out of #56082.

bank_clearance, pos_closing_entry, process_statement_of_accounts, party, utils, and
sales_invoice/services/pos converted to frappe.qb; bundles erpnext/utilities/query.py
(the get_match_conditions_qb helper, frappe#40075 follow-up) which
process_statement_of_accounts depends on. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:11 +05:30
Mihir Kandoi
13d06e77b4 refactor(postgres): port GL & ledger-core queries to the query builder
Pure MariaDB-identical conversion (raw frappe.db.sql -> frappe.qb / portable
functions) for Postgres compatibility. Split out of #56082.

general_ledger, gl_entry, gl_validator, period_closing_voucher, deferred_revenue,
process_payment_reconciliation + their tests. No behaviour change on MariaDB;
verified equivalent and the suites pass on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:00:55 +05:30
Mihir Kandoi
5787951ed1 fix(crm): guard first_contact result before indexing (lead conversion time)
Address review (#56105): the IS NOT NULL guard can return no rows (the count above
filters on sender, this query on recipients), so [0][0] would raise IndexError.
Fall back to None when empty, matching the prior behaviour for that case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:50:31 +05:30
Mihir Kandoi
3f6f3abf69 fix(selling): make multi-order invoice split sum exactly to the grand total
Address review (#56101): with 3+ orders the proportional shares could drift by a
sub-cent and not sum back to the grand total, leaving the last payment term
"Partly Paid". The last order (sorted, so MariaDB and Postgres agree) now absorbs
the residual: grand_total - sum(prior shares). Extended the test to three orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:48:36 +05:30
Mihir Kandoi
055c58364a Merge pull request #56090 from mihir-kandoi/pg-bom-rowshape
fix(manufacturing): make arbitrary GROUP-BY picks deterministic in BOM & transfer queries
2026-06-18 20:46:30 +05:30
Mihir Kandoi
cfa6d286ad fix(postgres): other-class parity fixes (case-folding & null-ordering) for merged queries
Parity sweep findings in queries already merged in develop:

- pos_invoice.py: serial_no return-match matched case-sensitively on Postgres (case-insensitive
  on MariaDB). Replaced the get_all or_filters lookup with a qb query that case-folds both sides
  via Lower() (no-op on MariaDB).
- controllers/queries.py + pick_list.py: the Locate()-based relevance ranking in link-query
  ORDER BY is case-sensitive on Postgres (Strpos) vs case-insensitive on MariaDB (Locate), so
  autocomplete order differed. Lower() both arguments so the ranking matches on both engines.
- crm/lead_conversion_time.py: "first contact" used ORDER BY communication_date LIMIT 1 read by
  index; a NULL date sorts first on MariaDB but last on Postgres, changing the result. Added
  `communication_date IS NOT NULL` so both engines return the earliest real contact date.

Verified on MariaDB and Postgres: test_pick_list 40/40, test_pos_invoice 26/26 on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:36:30 +05:30
vorasmit
b9b402f2ec fix: tax.base_tax_amount as none when payment entry created using API 2026-06-18 20:15:03 +05:30
Mihir Kandoi
b04a9e25ff fix(stock): make pick_list link query valid on Postgres (GROUP BY joined column)
get_pick_list_query selects Sales Order.customer (a joined table's column) while
grouping only by Pick List.name. Postgres' functional-dependency relaxation applies
to a table's own primary key, not to a joined table's columns, so the query raises
GroupingError on Postgres. MariaDB arbitrary-picks and runs.

customer is already pinned to a single value by `WHERE Sales Order.customer = filter`,
so adding it to the GROUP BY is identical on MariaDB and valid on Postgres.

Test (errors with GroupingError on the old code on Postgres, passes on both engines):
- test_get_pick_list_query_postgres_valid: a submitted pick list for a customer is
  returned by the link query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:14:31 +05:30
Mihir Kandoi
b1c6666d02 fix(selling): split multi-order invoice amount across its sales orders (payment terms status)
payment_terms_status_for_sales_order grouped invoice rows by `sii.parent` and took
`Max(sii.sales_order)`, so an invoice that bills several Sales Orders was credited
in full to one arbitrary order and the rest were starved of that payment.

get_so_with_invoices now returns one row per (invoice, sales_order) and splits the
invoice's grand total across the orders in proportion to each order's net line
amount on that invoice. A single-order invoice keeps the full grand total (ratio 1),
so the common case is unchanged; the split is pure Python over deterministic SQL,
so MariaDB and Postgres produce identical results (100% parity).

Test (fails on the old code, passes on both engines):
- test_invoice_billing_multiple_orders_splits_proportionally: one invoice billing two
  SOs 600/400 -> each order credited its share, summing to the grand total. Old
  Max(sales_order) collapsed the invoice onto one order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:44:31 +05:30
Mihir Kandoi
fe0465f16e fix(manufacturing): deterministic arbitrary-pick in BOM explosion & WO transfer tracking
Same class as the sub_assembly_queries / bom_stock_analysis fixes in this PR: two
more merged queries wrapped a non-functionally-dependent column in Max(), so the
engines can disagree and the value is wrong for the case the column drives.

bom_explosion._subitems_query — Max(is_phantom_item):
  Rows are grouped by item_code and get_subitems() drops any grouped row whose
  is_phantom_item is truthy. When one item_code is listed in a BOM both as a
  phantom sub-assembly and as a plain raw material, Max() returns 1 and the real
  raw material is silently dropped from the plan. Use Min(): an item is phantom
  only when EVERY line for it is phantom, so a real material is never lost.

required_items._material_transfer_qty_by_item — Max(original_item):
  original_item is the output dict key. The same item B can be transferred both
  for itself (original_item NULL) and as a substitute for required item A
  (original_item=A). Grouping by item_code alone with Max() merged the two and
  credited B's whole transfer to A, leaving B at 0. Group by
  (item_code, original_item) and accumulate into the keyed dict so each transfer
  is credited to the right required item (two rows can resolve to one key, e.g.
  A's own transfer and B-for-A, hence += not plain assignment).

Both were previously undefined SQL (loose GROUP BY); the fix makes MariaDB and
Postgres agree on the correct, deterministic value. Other Max()-wrapped columns
in these queries are functionally dependent on the grouped item and unchanged.

Tests (fail on the old code, pass on both engines):
- test_subitems_query_keeps_real_rm_listed_alongside_phantom
- test_transferred_qty_not_misattributed_between_item_and_its_substitute

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:03:26 +05:30
pandiyan
3ba8f690a4 fix: apply docstatus filter to exclude cancelled Work Orders in Serial No 2026-06-18 18:29:08 +05:30
rohitwaghchaure
47a9c54b70 Merge pull request #55931 from DipenFrappe/fix-rfq-accounting-dimensions
feat(rfq): add accounting dimensions support to Request for Quotation
2026-06-18 18:27:49 +05:30
Shllokkk
336307f287 Merge pull request #56087 from Shllokkk/pcv-je-validation
fix(journal entry): validate opening entry against pcv on save
2026-06-18 18:04:33 +05:30
Dipen Gala
79421bcfcc fix: set cost_center on RFQ item before submitting in test
The test was mutating an already-submitted RFQ, which raised
UpdateAfterSubmitError because cost_center lacks allow_on_submit.
Use do_not_submit=True, set cost_center on the draft, save, then submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:21:26 +05:30
Shllokkk
e23a7883f3 fix(journal entry): validate opening entry against pcv on save 2026-06-18 16:57:00 +05:30
Shllokkk
deff5848ed Merge pull request #55504 from aerele/fix/soa-show-opening-entries
fix(accounts): allow process statement of account generation with opening entries
2026-06-18 16:44:25 +05:30
Mihir Kandoi
b579dbc1e6 fix(manufacturing): prefer the phantom line as bom_stock_analysis representative
Address review on #56090: get_bom_data groups components by item_code, so it
picks one representative BOM Item line for the (bom_no, is_phantom_item) pair.
Taking the first line by idx dropped the phantom flag when a non-phantom line
was listed before the phantom one, so explode_phantom_boms skipped the sub-BOM.

Keep one row per item_code (preserving the qty_per_unit total per component
rather than widening the GROUP BY), but make the representative phantom-
preferring: the first line, upgraded to the first phantom line if any exists.
A phantom sub-BOM is therefore never dropped due to line order, on either engine.

Adds test_phantom_explosion_when_phantom_line_is_not_first (phantom line at
idx 2) alongside the existing idx-1 case; both pass on MariaDB and Postgres and
the new one fails on the naive first-line representative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:27:23 +05:30
Mihir Kandoi
41da9eb7fc fix(manufacturing): keep bom_no/is_phantom_item pair coherent in sub-BOM resolution
When a component is listed more than once in a BOM pointing at different
sub-BOMs (e.g. one phantom, one not), two queries grouped the duplicate
lines into a single row and aggregated bom_no and is_phantom_item with
*independent* Max(). That could pair the phantom flag of one line with the
bom_no of another, so the consumer recursed into the wrong sub-BOM:

- sub_assembly_queries._sub_assembly_rm_query keys on (item_code, bom_no)
  and recurses on is_phantom_item. An incoherent pair sent raw-material
  resolution down the wrong sub-assembly BOM.
- bom_stock_analysis.get_bom_data: explode_phantom_boms recurses into
  bom_no only when is_phantom_item is set; an incoherent pair exploded a
  non-phantom sub-BOM as if it were phantom (or vice-versa).

Fix:
- sub_assembly_queries: group also by (bom_no, is_phantom_item) so each
  distinct sub-BOM is its own coherent row.
- bom_stock_analysis: drop the two independent Max()es and attach a single
  representative line (lowest idx) per item_code before exploding.

This was previously undefined SQL (loose GROUP BY); the fix makes MariaDB
and Postgres agree on a deterministic, coherent pairing. Other Max()-wrapped
columns are functionally dependent on the grouped item and keep their value
on both engines.

Tests (fail on the old code, pass on both engines):
- test_phantom_explosion_picks_coherent_sub_bom: duplicate-component BOM
  explodes the phantom sub-BOM, not the lexically-larger non-phantom one.
- test_sub_assembly_rm_query_keeps_bom_no_phantom_pair_coherent: the query
  returns one coherent row per distinct sub-BOM with the right phantom flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:14:30 +05:30
Mihir Kandoi
1cc98a82ba fix(accounts): Journal Entry future payments mis-allocated to one invoice in AR
get_future_payments_from_journal_entry has no GROUP BY: the Sum() makes it an
implicit single-group aggregate, so every future-dated JE payment company-wide
collapses into ONE row keyed by an arbitrary (invoice, party). The Accounts
Receivable report then allocates the entire future sum against that one invoice and
shows zero future payment for every other invoice. This predates the postgres work
(MariaDB returned an arbitrary single row); the qb conversion only made the arbitrary
pick deterministic via Max().

Add an explicit GROUP BY (je.name, jea.reference_name, jea.party, jea.party_type,
je.posting_date, je.cheque_no) and drop the Max() wrappers, so each (JE, invoice,
party) is its own future-payment row -- matching the Payment Entry path and the
(invoice_no, party) keying the report's allocator already expects. Identical on
MariaDB and PostgreSQL.

Ships a JE-path future-payment test (one future JE paying two invoices -> each
invoice keeps its own future amount; fails on the old code with 0 != 50).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:53:57 +05:30
Mihir Kandoi
eb7f7f2124 Merge pull request #56081 from mihir-kandoi/pg-query-report-json
fix(postgres): make Query Report SQL portable across MariaDB and PostgreSQL
2026-06-18 15:18:49 +05:30
Nabin Hait
fcd312f205 refactor(sales_invoice): decompose SDBNB and change-amount GL methods
stock_delivered_but_not_billed_gl_entries (C/15) is split into a thin loop
plus _sdbnb_booking_for_item (eligibility + valuation), _is_sdbnb_reversal
(the account predicate) and _append_sdbnb_gl_entries (the two GL rows) —
guard clauses replace the nested continues. get_gle_for_change_amount now
uses the shared _amount_in_account_currency helper. No C-rank functions
remain in the file. Behaviour unchanged; characterization tests and the
full Sales Invoice suite (133) green.
2026-06-18 15:03:32 +05:30
Mihir Kandoi
3d4b50d37d fix(postgres): make Query Report SQL portable across MariaDB and PostgreSQL
Three Query Reports embedded double-quoted string literals and an unquoted table
identifier that error on PostgreSQL. Portability-only, no behaviour change on either engine:
- material_requests_for_which_supplier_quotations_are_not_created,
  requested_items_to_be_transferred: double-quoted string literals ("Stopped",
  "Material Transfer") -> single quotes (double quotes are identifiers on postgres, not strings).
- items_to_be_requested: quote the `tabBin` table identifier so postgres doesn't lower-case it.

(received_items_to_be_billed was dropped: it is a Script Report, so its `query` field is dead
code and the fix never reaches the DB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:59:01 +05:30
Nabin Hait
5de87f473e test(sales_invoice): characterize SDBNB and change-amount GL entries
Pin the two least-covered SalesInvoiceGLComposer methods before refactor:
- stock_delivered_but_not_billed_gl_entries: billing a perpetual Delivery
  Note via Sales Invoice reverses the SDBNB account into COGS for an equal
  amount.
- get_gle_for_change_amount: empty without change, mandatory-account error,
  and the debit-to / change-account entry pair.
2026-06-18 14:55:37 +05:30
rohitwaghchaure
31849f6029 Merge pull request #56079 from rohitwaghchaure/allow-negative-stock-for-batch-level
feat: allow negative stock at batch level
2026-06-18 14:31:09 +05:30
Nabin Hait
1f06f2e3a0 refactor(sales_invoice): simplify SalesInvoiceGLComposer GL builders
Extract two cross-cutting helpers used across the GL methods:
- _amount_in_account_currency: the repeated 'base if account is in company
  currency else transaction amount' ternary (customer, tax, item, POS and
  write-off entries).
- _return_aware_against_voucher: the return/self-outstanding against_voucher
  rule duplicated in the customer and POS entries.

Pull the per-item income and discount rows into their own builders and
flatten make_item_gl_entries with a guard clause. Complexity drops:
make_customer C11->B7, make_pos C12->B7, make_item C14->B10,
make_discount C13->B10, make_tax->A3. No behaviour change; entry dicts and
amounts are identical (full Sales Invoice suite green).

stock_delivered_but_not_billed_gl_entries is left for a coverage-first pass
(it is the least-tested GL branch).
2026-06-18 14:16:41 +05:30
Nabin Hait
3038ad8abe fix(sales_invoice): remove dead, non-functional POSService.get_warehouse
get_warehouse could never run: it filtered POS Profile on a non-existent
'user' column (users live in the applicable_for_users child table), and
embedded a Python bool (frappe.session["user"] == "") inside a query
builder predicate, which raises before reaching the database. It also
has no callers. Remove it and the now-unused msgprint import.
2026-06-18 14:00:07 +05:30
Khushi Rawat
dc202ac4a2 Merge pull request #56030 from Shllokkk/budget-distribution-grid-lock
fix: lock budget distribution table and guard against null distributi…
2026-06-18 13:58:07 +05:30
Rohit Waghchaure
ca07982ee0 feat: add batch-level option to allow negative stock for batch 2026-06-18 13:41:09 +05:30
Mihir Kandoi
f269f6a8d8 Merge pull request #56062 from mihir-kandoi/pg-accounts-pos
refactor(postgres): port Accounts POS, pricing & invoicing doctype queries to the query builder
2026-06-18 13:22:49 +05:30
Sudharsanan11
2ca1bdd8a7 test(stock): add test to validate the precision for additional cost amount 2026-06-18 12:12:05 +05:30
Sudharsanan11
cf338bb757 fix(stock): apply precision to the additional cost amount in stock entry 2026-06-18 12:12:05 +05:30
Khushi Rawat
bda7a8ced2 Merge pull request #54570 from khushi8112/item-opening-stock-dialog
feat: add opening stock dialog for stock items
2026-06-18 11:28:08 +05:30
Shllokkk
d37e5cd97d fix: lock budget distribution table and guard against null distribution rows 2026-06-18 11:23:39 +05:30
khushi8112
e57593fcf8 fix(item): add server-side guard for serial/batch items in make_opening_stock_entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 02:25:13 +05:30
khushi8112
c5b4a742b3 fix: so many conflicts 2026-06-18 02:05:24 +05:30
MochaMind
526f91f6b5 fix: Bosnian translations 2026-06-17 23:22:48 +05:30
MochaMind
4465ebaeb5 fix: Persian translations 2026-06-17 23:22:44 +05:30
Mihir Kandoi
88cb132fd1 refactor(postgres): port Purchase Invoice expense-account queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:39 +05:30
Mihir Kandoi
d23677636d refactor(postgres): port Purchase Invoice GL composer queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:38 +05:30
Mihir Kandoi
8e0ba50c4d refactor(postgres): port Purchase Invoice doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:37 +05:30
Mihir Kandoi
08abf96047 refactor(postgres): port Fiscal Year doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:37 +05:30
Mihir Kandoi
813b42d706 refactor(postgres): port Cost Center doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:36 +05:30
Mihir Kandoi
8ce63dac65 refactor(postgres): port Sales Taxes and Charges Template queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:35 +05:30
Mihir Kandoi
8e9680afce refactor(postgres): port Pricing Rule utils queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:35 +05:30
Mihir Kandoi
a09e875109 refactor(postgres): port Loyalty Point Entry doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:34 +05:30
Mihir Kandoi
42c61915c4 refactor(postgres): port POS Invoice doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:33 +05:30
Mihir Kandoi
37a6ebd431 refactor(postgres): port POS Profile doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:33 +05:30
Mihir Kandoi
65d9f78409 Merge pull request #56057 from mihir-kandoi/pg-accounts-payments 2026-06-17 18:59:43 +05:30
Mihir Kandoi
acda04a4bd refactor(postgres): port Payment Request doctype queries to the query builder
3-way merged onto develop (preserving the get_party_bank_account import move).
get_subscription_details passes order_by="" so get_all does not inject the
doctype default sort the raw query never had.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:02 +05:30
Mihir Kandoi
d1e167815f refactor(postgres): port Payment Entry doctype queries to the query builder
3-way merged onto develop, preserving develop's set_exchange_rate(ref_doc=doc) change.
One portable raw query is intentionally kept (as on the source branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:01 +05:30
Mihir Kandoi
588dfac4cd refactor(postgres): port Mode of Payment doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:00 +05:30
Mihir Kandoi
ac26c01e52 refactor(postgres): port Cashier Closing doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:00 +05:30
Mihir Kandoi
c0d2bd7bce refactor(postgres): port Invoice Discounting doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:37:59 +05:30
Mihir Kandoi
4d03e915f7 refactor(postgres): port Bank Transaction doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:37:58 +05:30
Mihir Kandoi
09beed9cc3 refactor(postgres): port Payment Order doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:37:57 +05:30
pandiyan
279c8dea06 fix: disable is_debit_note while creating credit note 2026-06-17 18:35:10 +05:30
Mihir Kandoi
0737a4cfbb Merge pull request #56051 from mihir-kandoi/pg-accounts-statements
refactor(postgres): port Accounts statement & ledger report queries to the query builder
2026-06-17 17:55:47 +05:30
Mihir Kandoi
1332ad7583 refactor(postgres): port Share Ledger report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:36:01 +05:30
Mihir Kandoi
058be399c3 refactor(postgres): port Asset Depreciation Ledger report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:36:01 +05:30
Mihir Kandoi
e482c846c8 refactor(postgres): port General Ledger report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:36:00 +05:30
Mihir Kandoi
6a60f072a8 refactor(postgres): port Dimension-wise Account Balance report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:59 +05:30
Mihir Kandoi
18c1f0f04d refactor(postgres): port Trial Balance report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:58 +05:30
Mihir Kandoi
217c107549 refactor(postgres): port Consolidated Trial Balance report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:57 +05:30
Mihir Kandoi
44ca5878b8 refactor(postgres): port Consolidated Financial Statement report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:56 +05:30
Mihir Kandoi
66e82c56b1 refactor(postgres): port Gross Profit report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:55 +05:30
Mihir Kandoi
65c0d35f2e refactor(postgres): port Budget Variance report query to the query builder
The raw get_fiscal_years query had no ORDER BY (de-facto oldest-first); the
get_all port adds explicit order_by="name asc" so the Fiscal Year doctype
default (name DESC) does not reverse the report column order / cumulative values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:55 +05:30
Mihir Kandoi
e9eca10927 refactor(postgres): port Cash Flow report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:54 +05:30
Mihir Kandoi
6849d292f8 refactor(postgres): port financial statements helper queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:52 +05:30
Mihir Kandoi
261b7fe7aa Merge pull request #56047 from mihir-kandoi/pg-accounts-registers
refactor(postgres): port Accounts register report queries to the query builder
2026-06-17 17:09:03 +05:30
khushi8112
03be975f26 fix(stock): create opening stock via Stock Reconciliation with serial/batch bundle support 2026-06-17 17:02:34 +05:30
khushi8112
70086f92f5 fix: test case 2026-06-17 16:54:32 +05:30
khushi8112
e602cad39a fix: use Stock Reconciliation for opening stock entry 2026-06-17 16:54:26 +05:30
khushi8112
60f528b531 fix: minor UI and UX fixes 2026-06-17 16:52:19 +05:30
Mihir Kandoi
30568d36d0 refactor(postgres): port Accounts Receivable report query to the query builder
Most queries are straight raw-SQL -> query-builder ports. One rider:
get_future_payments_from_journal_entry sums future amounts with no GROUP BY,
so its non-aggregated identity columns (invoice_no/party/future_date/future_ref)
are wrapped in Max() to satisfy postgres strict GROUP BY. The summed amount is
unchanged; the attributed invoice/party label stays within MariaDB's existing
arbitrary-row indeterminacy for that already-aggregated single-row query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:49:48 +05:30
Mihir Kandoi
8527e78820 refactor(postgres): port POS Register report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:46:32 +05:30
Mihir Kandoi
ae4a5e82b0 refactor(postgres): port Item-wise Purchase Register report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:46:31 +05:30
Mihir Kandoi
196fce9792 refactor(postgres): port Purchase Register report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:46:30 +05:30
Mihir Kandoi
449004d29a refactor(postgres): port Sales Register report query to the query builder
Also sort the distinct income / unrealized P&L account lists in python:
frame drops ORDER BY for distinct queries on postgres, so the generated
account-column order must be pinned in python to stay deterministic on
both backends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:46:29 +05:30
khushi8112
e00cfc7c2a feat: add opening stock dialog box in Item form 2026-06-17 16:44:22 +05:30
Mihir Kandoi
bae3668bd0 Merge pull request #56045 from mihir-kandoi/pg-final-fixes
fix(postgres): final fix-class changes (asset GROUP BY + accounts-controller boolean)
2026-06-17 16:32:13 +05:30
Mihir Kandoi
8ce0e5386a Merge pull request #56042 from mihir-kandoi/codex/fix-stock-ageing-reco-ageing
fix: preserve stock ageing on non-serial reconciliation
2026-06-17 16:15:26 +05:30
Mihir Kandoi
4ba042c7c7 fix(postgres): compare Check fields with == 1 in accounts controller CASE/condition
disable_rounded_total and is_pos are smallint Check fields; postgres rejects
using them as bare boolean conditions in CASE WHEN / bitwise-AND, so compare
explicitly against 1. No-op on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:09:00 +05:30
Mihir Kandoi
59ad76c21e fix(postgres): satisfy strict GROUP BY in asset depreciation query
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:08:24 +05:30
Mihir Kandoi
1efe0be379 Merge pull request #56041 from mihir-kandoi/pg-engine-semantics
fix(postgres): assorted engine-semantics fixes (casing, null, boolean)
2026-06-17 16:07:07 +05:30
Mihir Kandoi
6bb7fa6d68 fix: preserve stock ageing on non-serial reconciliation 2026-06-17 15:55:22 +05:30
Mihir Kandoi
ef5feb613a fix(postgres): default empty balance_serial_no to "" in Available Serial No report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:42:04 +05:30
Mihir Kandoi
2fa9d7cee6 fix(postgres): match stored "Exchange Gain Or Loss" voucher_type casing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:42:02 +05:30
Mihir Kandoi
431dc208b3 Merge pull request #56040 from mihir-kandoi/pg-fn-swap
fix(postgres): replace MySQL-only SQL functions with portable equivalents
2026-06-17 15:40:43 +05:30
Mihir Kandoi
0b795a628f fix(postgres): use portable GroupConcat in Lost Opportunity report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:19:16 +05:30
Mihir Kandoi
595a4c8517 fix(postgres): replace MySQL IF() with Case in Cheques and Deposits report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:50 +05:30
Mihir Kandoi
9ec224c3fd fix(postgres): use CombineDatetime for work order stock-entry timestamps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:49 +05:30
Mihir Kandoi
68415c341b fix(postgres): use portable DateDiff in supplier scorecard variables
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:48 +05:30
Mihir Kandoi
a43df3278f fix(postgres): use portable DateDiff/CurDate in Inactive Sales Items report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:47 +05:30
Mihir Kandoi
4d06b01abf Merge pull request #56037 from mihir-kandoi/pg-groupby-doctypes
fix(postgres): satisfy strict GROUP BY in doctype & core queries
2026-06-17 15:00:55 +05:30
ruthra kumar
a04d54b2fb Merge pull request #56034 from ruthra-kumar/remove_custom_utility_for_company_creation
refactor(test): remove custom utility for company creation
2026-06-17 14:13:22 +05:30
rohitwaghchaure
0476f318e4 Merge pull request #55807 from aerele/fix/support-#70713
fix(stock): allow partial raw material pick/transfer from work order
2026-06-17 14:10:29 +05:30
Mihir Kandoi
7fbfa35f95 fix(postgres): satisfy strict GROUP BY in serial and batch bundle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:56 +05:30
Mihir Kandoi
bbf506e848 fix(postgres): satisfy strict GROUP BY in work order required items
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:55 +05:30
Mihir Kandoi
725fd8ca97 fix(postgres): satisfy strict GROUP BY in production plan sub-assembly queries
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:54 +05:30
Mihir Kandoi
85191d1cac fix(postgres): satisfy strict GROUP BY in production plan bom explosion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:53 +05:30
Mihir Kandoi
93021a9d45 fix(postgres): satisfy strict GROUP BY in accounts advances service
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:52 +05:30
Mihir Kandoi
0afc6dd363 fix(postgres): satisfy strict GROUP BY in unreconcile payment
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:51 +05:30
Mihir Kandoi
6dc2e43dd6 fix(postgres): satisfy strict GROUP BY in process period closing voucher
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:50 +05:30
Mihir Kandoi
d34e4b8783 fix(postgres): satisfy strict GROUP BY in exchange rate revaluation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:49 +05:30
Mihir Kandoi
501acd0414 fix(postgres): satisfy strict GROUP BY in bank reconciliation tool
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:48 +05:30
Mihir Kandoi
113943f851 Merge pull request #56036 from mihir-kandoi/pg-groupby-reports
fix(postgres): satisfy strict GROUP BY in 12 reports
2026-06-17 13:39:35 +05:30
Mihir Kandoi
c124e90a89 fix(postgres): satisfy strict GROUP BY in Total Stock Summary report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:08 +05:30
Mihir Kandoi
9096b4a9df fix(postgres): satisfy strict GROUP BY in Stock And Account Value Comparison report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:07 +05:30
Mihir Kandoi
e69eaa5102 fix(postgres): satisfy strict GROUP BY in Product Bundle Balance report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:06 +05:30
Mihir Kandoi
e77b27ae99 fix(postgres): satisfy strict GROUP BY in Batch Wise Balance History report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:04 +05:30
Mihir Kandoi
60235f4b2b fix(postgres): satisfy strict GROUP BY in Payment Terms Status For Sales Order report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:03 +05:30
Mihir Kandoi
463103ebf1 fix(postgres): satisfy strict GROUP BY in Inactive Customers report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:02 +05:30
Mihir Kandoi
a3ec98a57c fix(postgres): satisfy strict GROUP BY in Process Loss Report report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:02 +05:30
Mihir Kandoi
9ab8803fed fix(postgres): satisfy strict GROUP BY in Bom Stock Analysis report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:01 +05:30
Mihir Kandoi
8a566e6ba5 fix(postgres): satisfy strict GROUP BY in Sales Pipeline Analytics report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:00 +05:30
Mihir Kandoi
812a06cf44 fix(postgres): satisfy strict GROUP BY in Requested Items To Order And Receive report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:18:59 +05:30
Mihir Kandoi
23778c3875 fix(postgres): satisfy strict GROUP BY in Voucher Wise Balance report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:18:57 +05:30
Mihir Kandoi
24a66d10e7 fix(postgres): satisfy strict GROUP BY in Deferred Revenue And Expense report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:18:56 +05:30
Mihir Kandoi
3faaa87645 Merge pull request #56033 from mihir-kandoi/pg-zero-date
fix(postgres): db-aware zero-date (0000-00-00) item end-of-life checks
2026-06-17 12:57:22 +05:30
ruthra kumar
afeaba5142 refactor(test): update assertion for new test records 2026-06-17 12:39:29 +05:30
Mihir Kandoi
1a016cbcd6 refactor(postgres): consistent zero-date pattern (address review)
Use the same explicit db-aware conditional-add as work_order/mapper.py for the
end_of_life check (shared _item_is_alive helper in reorder_item.py; inline in
stock_projected_qty.py) instead of the inline ternary that became == None on
postgres. Identical SQL, no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:35:29 +05:30
Mihir Kandoi
7532ec9f9a fix(postgres): db-aware zero-date (0000-00-00) item end-of-life checks
The "item is not discontinued" checks treat an item as alive when its
`end_of_life` is unset, in the future, or the MariaDB zero-date `'0000-00-00'`.
`'0000-00-00'` is an invalid date literal on PostgreSQL (it errors), and a
"not set" end_of_life is `NULL` there anyway — already covered by the existing
`end_of_life IS NULL` term. So the zero-date comparison is applied on MariaDB
only; PostgreSQL keeps the `IS NULL` / future-date terms. No behaviour change on
MariaDB.

Sites: work order item-master selection (`mapper.py`), reorder-level item
selection (`reorder_item.py`), and the Stock Projected Qty report.

Part of the staged MariaDB<->PostgreSQL parity rollout (one problem class).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:23:15 +05:30
Mihir Kandoi
48e66d04e6 Merge pull request #56025 from mihir-kandoi/pg-row-locking-cursor
fix(postgres): db-aware row-locking, savepoints & cursors
2026-06-17 12:19:55 +05:30
Rohit Waghchaure
b1b6ae98ed perf: composite index on (serial_no, warehouse, posting_datetime) 2026-06-17 12:17:11 +05:30
ruthra kumar
59a69fc497 refactor(test): broken test case in accounts controller 2026-06-17 10:22:00 +05:30
Mihir Kandoi
a899183087 fix(postgres): keep row locks via lock-then-read (address review)
Acquire the same row locks on postgres that MariaDB takes, via a separate plain
SELECT <pk> ... FOR UPDATE before each grouped/aggregate read (FOR UPDATE is
invalid with GROUP BY on postgres). Applied to all 6 aggregate lock sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:50:33 +05:30
ruthra kumar
3d109571ee refactor(test): remove even more dead code 2026-06-17 08:31:38 +05:30
Mihir Kandoi
d079677500 fix(postgres): db-aware row-locking, savepoints & cursors
PostgreSQL rejects `SELECT ... FOR UPDATE` when combined with `GROUP BY`,
aggregates or `DISTINCT`, has no concept of MySQL's locking semantics for those
shapes, and its server-side (unbuffered) cursors can't run nested queries
mid-iteration. This makes the row-locking / cursor paths db-aware so they keep
the exact MariaDB behaviour there and use the valid PostgreSQL form on Postgres.

One problem class, applied across the codebase:

- **`FOR UPDATE` + GROUP BY/aggregate** — keep `.for_update()` on MariaDB; on
  Postgres acquire the lock in a separate plain `SELECT ... FOR UPDATE` pass (or
  skip where the grouped read isn't a lock point). Deprecated serial/batch,
  serial-batch-bundle, pick list, stock reservation entry.
- **Unbuffered/server-side cursor** — Stock Ageing streamed via an unbuffered
  cursor and then ran nested queries; on Postgres that invalidates the cursor, so
  process the buffered result directly there.
- **Transaction savepoints** — Opening Invoice Creation Tool rolled back the whole
  transaction per failed invoice (which on Postgres also discards sibling rows and
  earlier error logs); scope each invoice to a savepoint instead.

No behaviour change on MariaDB (the locking/cursor path is unchanged there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:20:23 +05:30
ruthra kumar
6e62750c2f refactor(tests): reuse persistent master data instead of creating company per test
Replace per-test company creation in setUp() with persistent master data
from BootStrapTestData. Add Test PCV Company to test_records.json so it
becomes a persistent fixture rather than a throwaway created per test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 08:12:54 +05:30
Diptanil Saha
faadc1620b fix(company): replaced "this company" with company name on delete transactions dialog (#56021) 2026-06-17 02:11:08 +05:30
MochaMind
f249d57b30 fix: sync translations from crowdin (#56018) 2026-06-17 00:54:16 +05:30
Mihir Kandoi
3f66541b99 Merge pull request #56020 from frappe/revert-56008-pg-manufacturing-projects
Revert "refactor(manufacturing, projects): make raw SQL portable to PostgreSQL (parity rollout 2/9)"
2026-06-16 23:50:48 +05:30
Mihir Kandoi
e7c2f8ee11 Merge pull request #56019 from frappe/revert-55994-pg-selling-buying
Revert "refactor(selling, buying): make raw SQL portable to PostgreSQL (parity rollout 1/9)"
2026-06-16 23:48:48 +05:30
Mihir Kandoi
8dacf62da0 Revert "refactor(manufacturing, projects): make raw SQL portable to PostgreSQ…"
This reverts commit 2d24eedab2.
2026-06-16 23:29:28 +05:30
Mihir Kandoi
935746e752 Revert "refactor(selling, buying): make raw SQL portable to PostgreSQL (parity rollout 1/9)" 2026-06-16 23:28:53 +05:30
Nikhil Kothari
4e2a10e496 fix: check for bank account permission when updating balance (#56016)
* fix: check for bank account permission when updating balance

* fix: add company to bank balance doctype
2026-06-16 17:23:55 +00:00
ruthra kumar
a32c784084 Merge pull request #55988 from ruthra-kumar/dropping_accountstestmixin
refactor(test): remove dependency on accounts test mixin
2026-06-16 22:02:21 +05:30
Mihir Kandoi
2d24eedab2 refactor(manufacturing, projects): make raw SQL portable to PostgreSQL (parity rollout 2/9) (#56008)
refactor(manufacturing, projects): make raw SQL portable to PostgreSQL

Convert the MariaDB-only raw `frappe.db.sql` in the Manufacturing and Projects
modules to the cross-database query builder / ORM, and fix the non-portable
constructs that remain. Every change is a no-op on MariaDB (identical rendered
SQL / identical results) and only brings PostgreSQL — standards-strict where
MySQL is lax — in line.

Areas: BOM (cost/where-used/explosion), Work Order (operations, required items,
mapper, stock report), Workstation, Production Plan sub-assembly/explosion
queries, BOM Stock Analysis / Process Loss / Work Order Stock reports; Projects
(project, task, timesheet, activity cost, project update), Daily Timesheet
Summary and Project-wise Stock Tracking reports.

Part of the staged MariaDB<->PostgreSQL parity rollout (module 2 of 9).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:20:36 +00:00
dependabot[bot]
ef1fbb7899 chore(deps): bump vite from 8.0.11 to 8.0.16 in /banking (#56007) 2026-06-16 16:00:43 +00:00
rohitwaghchaure
b5ecc9e6bd Merge pull request #55983 from rohitwaghchaure/fixed-recalculate-valuation-rate-in-pr
fix: provision to recalculate valuation rate during reposting
2026-06-16 21:21:59 +05:30
Mihir Kandoi
f195044fd1 Merge pull request #55994 from mihir-kandoi/pg-selling-buying
refactor(selling, buying): make raw SQL portable to PostgreSQL (parity rollout 1/9)
2026-06-16 21:18:57 +05:30
ruthra kumar
004087097c refactor(test): remove redundant clear method and minor fixes 2026-06-16 20:40:03 +05:30
Mihir Kandoi
992015424b Merge pull request #55978 from aerele/driver-permission
fix: update system manager permissions
2026-06-16 20:36:01 +05:30
Mihir Kandoi
a86b169d8b Merge pull request #55974 from aerele/fix/support-70978
fix(stock): update transfer status for mixed transfer flows
2026-06-16 20:34:14 +05:30
Rohit Waghchaure
e183e32619 fix: test case 2026-06-16 20:09:44 +05:30
Rohit Waghchaure
28992eb2f4 test: reset dont_execute_stock_reposts flag in tearDown
The test_prevention_of_cancelled_transaction_riv test sets
frappe.flags.dont_execute_stock_reposts = True without resetting it,
which leaked into the recalculate_valuation_rate tests and made
repost() a no-op (incoming rate stayed unchanged). Reset the flag
in tearDown so reposts run for subsequent tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 19:53:27 +05:30
Rohit Waghchaure
0ae61c4921 fix: provision to recalculate valuation rate during reposting 2026-06-16 19:47:48 +05:30
rohitwaghchaure
8190696d36 Merge pull request #55971 from rohitwaghchaure/fixed-actual-tax-amount
fix: exclude non-stock item's tax value from stock valuation
2026-06-16 19:10:50 +05:30
rohitwaghchaure
b12032485b Merge pull request #55986 from rohitwaghchaure/removed-redundant-validation
chore: removed redundant validation in SCR
2026-06-16 19:08:00 +05:30
Mihir Kandoi
be0f571d62 refactor(selling, buying): make raw SQL portable to PostgreSQL
Convert the MariaDB-only raw `frappe.db.sql` in the Selling and Buying
modules to the cross-database query builder / ORM, and fix the few
non-portable constructs that remain. Every change is a no-op on MariaDB
(identical rendered SQL / identical results) and only brings PostgreSQL —
which is standards-strict where MySQL is lax — in line.

Patterns addressed in these modules:

- Strict GROUP BY — PostgreSQL rejects SELECTing a non-aggregated column
  that isn't functionally dependent on the grouped key. Sales Order
  Analysis, Sales Analytics, Purchase Order Analysis and Procurement
  Tracker now group by the PK (1:1 with the existing key, so no behaviour
  change) or aggregate genuinely-independent columns.
- App clock vs DB clock — Sales Order Analysis computed delay against the
  database CURRENT_DATE, which differs from the app's today by a day when
  the DB runs a different timezone; switched to `nowdate()` (deterministic,
  identical on both DBs).
- Portable date math / functions — DATEDIFF and friends via the db-aware
  query-builder functions.
- Raw SQL → query builder for the remaining self-contained selling/buying
  reads (POS item search, customer naming suffix, packing-items
  availability, customer credit/acquisition reports).

Part of the staged MariaDB↔PostgreSQL parity rollout (module 1 of 9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:06:19 +05:30
Mihir Kandoi
3a1e4d14f3 Merge pull request #55985 from aerele/fix/batch-item-link-filters
fix(stock): show only batched items in batch item selector
2026-06-16 18:29:44 +05:30
ruthra kumar
1fda0dfb9b refactor(tests): replace AccountsTestMixin master data setup with direct attribute assignments
All test classes inheriting AccountsTestMixin that called create_company(),
create_item(), create_customer(), create_supplier(), create_usd_receivable_account(),
and create_usd_payable_account() in setUp() now set instance attributes directly
using master data pre-created by BootStrapTestData, eliminating redundant DB
inserts on every test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:55:42 +05:30
Rohit Waghchaure
1b4487450c chore: removed redundant validation in SCR 2026-06-16 17:51:54 +05:30
Mihir Kandoi
9564f677e4 Merge pull request #55982 from frappe/codex/bom-secondary-item-modified-timestamp
fix: bump BOM secondary item modified timestamp
2026-06-16 17:39:52 +05:30
Dharanidharan2813
62f6d18143 fix(stock): show only batched items in batch item selector 2026-06-16 17:35:41 +05:30
pandiyan
1dbdf85ddc test(stock): validate completed status for mixed transfer methods 2026-06-16 17:06:26 +05:30
Mihir Kandoi
026ec8a6d9 fix: bump BOM secondary item modified timestamp 2026-06-16 16:52:16 +05:30
Mihir Kandoi
a9207f1e12 Merge pull request #55851 from frappe/feat-stock-balance-alt-uom-columns
feat: add alternate UOM balance columns to Stock Balance report
2026-06-16 16:43:03 +05:30
Nabin Hait
2a5ba9050e Merge pull request #55943 from aerele/ignore_cancelled_GLE
refactor: ignore cancelled GLE's while looking for account
2026-06-16 16:36:18 +05:30
pandiyan
02d41b1dac fix(stock): update transfer status for mixed transfer flows 2026-06-16 16:28:32 +05:30
nareshkannasln
501c8087cb fix: update system manager permissions 2026-06-16 16:08:52 +05:30
Rohit Waghchaure
13e1f84eb1 fix: exclude non-stock item's tax value from stock valuation 2026-06-16 15:35:18 +05:30
Nabin Hait
75394baa28 Merge pull request #55947 from nabinhait/fix/clearance-date-on-amend-54909
fix(accounts): clear clearance date when amending reconciled voucher
2026-06-16 15:28:22 +05:30
Nabin Hait
fb59f825ee Merge pull request #55785 from aerele/fix-payment-entry-transaction-currency
fix: set transaction currency on payment entry gl entries
2026-06-16 15:16:04 +05:30
Nabin Hait
34f78f7261 Merge pull request #55896 from nabinhait/fix/fixed-asset-turnover-ratio
fix: use net fixed assets for Fixed Asset Turnover Ratio
2026-06-16 15:13:03 +05:30
Dipen Gala
987f606b4d fix: resolve pre-commit formatting and missing UOM in stock balance test
- Reformat generator expression in add_alt_uom_columns to satisfy ruff
  line-length rule (pre-commit was auto-fixing this and failing CI)
- Create "Carton" UOM before use in test_alt_uom_balance_uses_first_alternate_uom
  to avoid LinkValidationError when "Carton" doesn't exist in test DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 15:10:00 +05:30
Mihir Kandoi
b1b510c824 Merge pull request #55830 from aerele/fix/qi-stock-entry-inspection
fix(stock): enable quality inspection for all Stock Entry purposes
2026-06-16 15:07:47 +05:30
Mihir Kandoi
066158174e Merge pull request #55852 from umairsy/fix/allow-zero-qty-bom-secondary-items
fix(bom): allow zero qty for secondary items in BOM
2026-06-16 15:05:31 +05:30
Diptanil Saha
07d073da0d fix(accounts): removed whitelist on get_balance_on (#55956) 2026-06-16 14:34:52 +05:30
Sudharsanan11
ba1b1ee20d test(stock): add test to validate the partial transfer of raw material 2026-06-16 14:20:15 +05:30
Sudharsanan11
213adc9ebe fix(stock): allow partial raw material picking/transfer from work order
When creating a pick list for a work order with partially available stock,
the resulting Material Transfer for Manufacture stock entry was setting
fg_completed_qty = for_qty (= wo.qty), causing material_transferred_for_manufacturing
to reach wo.qty after just one partial transfer and blocking further pick lists.

Fix:
- Set fg_completed_qty = 0 on stock entries created from pick lists so the
  old SUM(fg_completed_qty) path never fires prematurely
- Recompute material_transferred_for_manufacturing after each transfer:
  use SUM(fg_completed_qty) when > 0 (direct entries / excess transfer),
  otherwise use min(transferred/required) × wo.qty (pick list flow)
- Add _validate_no_excess_transfer for pick list entries (fg_completed_qty=0)
  to prevent transferring more than pending qty; skip for return entries
  and when backflush is based on Material Transferred for Manufacture
- Remove the zero-qty prompt in pick list work_order trigger; skip the
  qty dialog in work_order.js when max transferable qty is already 0
- Hide fg_completed_qty field in Stock Entry for Material Transfer for
  Manufacture purpose since it is unused in that flow

Fixes: #70713, #63846
2026-06-16 14:20:15 +05:30
Nabin Hait
0e7d45b1af fix: minor fix
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-16 14:08:15 +05:30
ruthra kumar
7e602d5389 Merge pull request #53152 from aerele/fix_payment_entry
fix: prevent exchange rate flow from transaction to payment
2026-06-16 14:02:55 +05:30
rohitwaghchaure
529f8dc7cd Merge pull request #55826 from rohitwaghchaure/moved-files-to-services
refactor: moved files from stock_entry_handler to services
2026-06-16 13:57:08 +05:30
Sudharsanan11
609ccc3cb1 test(stock): add test to validate the quality inspection for stock entry 2026-06-16 13:11:06 +05:30
Sudharsanan11
dceb9a3c6c fix(stock): enable quality inspection for all Stock Entry purposes
- Remove `depends_on` restriction from `inspection_required` field so it
  is visible for all Stock Entry purposes, not just Manufacture
- Fix `check_item_quality_inspection` to return items for Stock Entry
  (was returning [] for unknown doctypes, blocking QI creation flow)
- Fix `inspection_type` in transaction.js to be purpose-aware: Manufacture
  and Material Receipt → "Incoming"; all other purposes → "Outgoing"
2026-06-16 12:34:55 +05:30
Shllokkk
52b406f5f1 fix(budget): add root_type filter on account field (#55934) 2026-06-16 11:43:52 +05:30
MochaMind
3dda2005d8 fix: sync translations from crowdin (#55900) 2026-06-16 11:13:36 +05:30
Jatin3128
322d4dff25 fix: clear stale payment rows on non-POS returns so they don't surface in bank reconciliation (#55903) 2026-06-16 10:42:36 +05:30
ruthra kumar
01a10fb5b0 Merge pull request #55949 from ruthra-kumar/speed_up_item_wise_inventory_account_tests
refactor(test): speed up item wise inventory test
2026-06-16 08:47:00 +05:30
ruthra kumar
4c084f7eff Merge pull request #55948 from ruthra-kumar/transaction_deletion_record_test_speed_up
refactor(test): faster transaction deletion record tests
2026-06-16 08:34:03 +05:30
ruthra kumar
627f2058b5 refactor(test): speed up item wise inventory test 2026-06-16 08:23:45 +05:30
ruthra kumar
8db4d2705a refactor(test): dont create master data in setUp 2026-06-16 08:03:47 +05:30
Nabin Hait
1a8d73cbbe fix(accounts): clear clearance date when amending reconciled voucher
The framework ignores `no_copy` while amending, so a reconciled voucher
carried a stale clearance date into its amendment even though the linked
bank transaction gets unreconciled on cancellation. Reset it via a shared
`before_insert` hook on AccountsController.

Fixes #54909
2026-06-16 00:03:41 +05:30
ruthra kumar
4ca7bc8ccf Merge pull request #55942 from ruthra-kumar/speed_up_delivery_note_tests
refactor(test): dont create company in setUp of Deliv Note
2026-06-15 20:16:16 +05:30
ervishnucs
40942401df refactor: ignore cancelled GLE's while looking for account 2026-06-15 18:22:30 +05:30
rohitwaghchaure
ca5cc4afdc Merge pull request #55928 from aerele/fix/support-#70854
fix(stock): update stock value calculation in stock balance report
2026-06-15 18:10:23 +05:30
Jatin3128
380b005659 fix: fiscal year check on validation (#55930) 2026-06-15 18:08:05 +05:30
ruthra kumar
df0ad93262 refactor(test): dont create company in setUp of Deliv Note 2026-06-15 17:51:02 +05:30
ruthra kumar
f503614cc0 Merge pull request #55929 from ruthra-kumar/parallelize_and_optimize_install_helper
ci: optimize install helper setup
2026-06-15 17:39:24 +05:30
Rohit Waghchaure
6d9beea56b refactor: moved files from stock_entry_handler to services 2026-06-15 17:28:50 +05:30
rohitwaghchaure
560d8bb674 Merge pull request #55926 from rohitwaghchaure/fixed-recalculate-rate-for-purchase-doc
fix: recalculate incoming rate in SLE for purchase documents during repost
2026-06-15 17:03:56 +05:30
Dipen Gala
e91bcd6dd6 feat(rfq): add accounting dimensions support to Request for Quotation
- Add `cost_center` field and `accounting_dimensions_section` / `dimension_col_break`
  to Request for Quotation Item DocType so custom accounting dimensions propagate automatically
- Register `Request for Quotation Item` in `accounting_dimension_doctypes` hook
- Map `cost_center` from Material Request → RFQ in `make_request_for_quotation`
- Map `cost_center` from RFQ → Supplier Quotation in `make_supplier_quotation_from_rfq`
  and `create_rfq_items` (portal flow)
- Map `cost_center` in `get_item_from_material_requests_based_on_supplier` (MR-based RFQ flow)
- Add test cases to verify cost_center propagation through the MR → RFQ → SQ chain

Fixes #55855

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:58:57 +05:30
Mihir Kandoi
a3e3e1b32c ci: optimize install helper setup
Cc: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:50:50 +05:30
Sudharsanan11
2492dfa558 fix(stock): update stock value calculation in stock balance report 2026-06-15 16:27:18 +05:30
ervishnucs
3b5a203d61 test: resolve failed testcases for exchage rate 2026-06-15 16:20:04 +05:30
ervishnucs
934abe5c6d fix: prevent exchange rate flow from transaction to payment 2026-06-15 16:20:04 +05:30
Rohit Waghchaure
867ee484b9 fix: recalculate incoming rate in SLE for purchase documents during repost 2026-06-15 16:09:03 +05:30
Mohammad Umair Sayed
c1bef53f92 refactor(bom): drop redundant secondary item qty None check
Float fields default to 0, so qty is never None. Per review feedback,
remove the validation entirely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:06:24 +05:30
Diptanil Saha
2652082475 Merge pull request #55755 from diptanilsaha/feat/ces_frankfurter_v2
feat(currency exchange settings): frankfurter v2 support
2026-06-15 14:22:49 +05:30
ljain112
35e55d3e13 fix: update weighted average rate calculation to consider returned and consumed quantities 2026-06-15 14:16:07 +05:30
diptanilsaha
abb579e2db fix(get_exchange_rate): using get_single_value to fetch disabled value from currency_exchange_settings 2026-06-15 13:52:34 +05:30
diptanilsaha
0c2d5488a6 fix: restricting currency_exchange_settings write permission only to system manager 2026-06-15 13:52:34 +05:30
diptanilsaha
138f683a68 test: fixed currency exchange test for frankfurter v2 api 2026-06-15 13:52:27 +05:30
diptanilsaha
479f9f63c9 fix: use frankfurter v2 by default for new install 2026-06-15 13:52:06 +05:30
diptanilsaha
56bfe6b6a6 feat(currency exchange settings): frankfurter v2 support 2026-06-15 13:52:06 +05:30
rohitwaghchaure
acae34c8e1 Merge pull request #55901 from rohitwaghchaure/fixed-regression-security-fixes
fix: regression issues related to security fixes
2026-06-15 12:20:38 +05:30
Mihir Kandoi
dcbe4a6d55 Merge pull request #55906 from raghavisruia/develop
fix: show company name in delete transactions confirmation dialog
2026-06-15 09:49:59 +05:30
Raghav Ruia
87d26a2d67 fix: show company name in delete transactions confirmation dialog
Display the actual company name in bold within the confirmation dialog
label so users immediately know which company they must type to confirm,
reducing the risk of accidental data loss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:45:55 +05:30
Rohit Waghchaure
e1d8d06966 refactor: consolidate duplicate get_party_bank_account into bank_account.py 2026-06-14 23:53:35 +05:30
Rohit Waghchaure
8c88cecc1f fix: regression issues related to security fixes 2026-06-14 23:42:48 +05:30
MochaMind
9aeafb8140 fix: sync translations from crowdin (#55784) 2026-06-14 17:37:37 +00:00
Nabin Hait
6f9a8ff101 fix: don't convert item margin on exchange rate refresh
Changing the transaction/posting date re-triggers the `currency` handler,
which fetches a fresh exchange rate and, whenever it differs from the
current one, divided every Amount-type item margin and Actual tax charge
by the new rate. `margin_rate_or_amount` is an amount in the transaction
currency, so dividing it is only meaningful when the document actually
switches currency; on a mere rate refresh it silently shrinks the margin
every time.

Track the currency the rendered document is denominated in and convert
margins/actual charges only on a real currency change, while still
updating `conversion_rate` so base amounts recalculate correctly.

Also remove the duplicated `currency()` override in quotation.js: it
re-ran the same fetch-and-convert block after `super.currency()` (double
converting margins) and lacked the `load_after_mapping` guard. The base
handler already covers Quotation via `transaction_date`.

Fixes #45210
2026-06-14 20:32:56 +05:30
Nabin Hait
8e627db785 fix(stock): use correct field when reading previous stock closing balance
`StockClosing.get_sle_entries` filtered `Stock Closing Balance` by a
`closing_stock_balance` column that does not exist; the link field back to
the closing entry is `stock_closing_entry`. As a result every Stock
Closing Entry created after the first one failed with
`OperationalError (1054, "Unknown column 'closing_stock_balance'")`, since
the previous-balance branch only runs once an earlier closing exists.

Fixes #54819
2026-06-14 20:25:57 +05:30
Nabin Hait
b2eb6a69c1 fix(projects): don't overwrite existing Sales Order project link
Creating a Project with a `sales_order` set used `frappe.db.set_value`
to unconditionally write the Sales Order's `project` field. When several
projects were created for the same Sales Order, each new project silently
overwrote the previous link, leaving earlier projects orphaned.

Only back-link the Sales Order when it is not already tied to another
project, and write the value through the document's `db_set` so the
modified timestamp and realtime update are handled. A warning is shown
when an existing link is left untouched.

Fixes #52179
2026-06-14 20:17:08 +05:30
Nabin Hait
986af3852c fix: use net fixed assets for Fixed Asset Turnover Ratio
The Fixed Asset Turnover Ratio in the Financial Ratios report divided
Net Sales by Total Assets (the root-level Asset group), which actually
computes the Total Asset Turnover Ratio.

Populate a `fixed_asset` balance from the asset account carrying the
`Fixed Asset` account_type (mirroring how `current_asset` is derived for
`Current Asset`) and use it as the denominator, so the ratio reflects
Net Sales / Net Fixed Assets per the standard definition.

Fixes #54529
2026-06-14 20:06:32 +05:30
MochaMind
c24e9796ae chore: update POT file (#55894) 2026-06-14 13:11:21 +02:00
rohitwaghchaure
c7d42e161b Merge pull request #55877 from rohitwaghchaure/feat-allow_to_edit_stock_uom_qty_for_stock_entry
feat: Allow to edit stock UOM qty for Stock Entry
2026-06-14 09:54:24 +05:30
Raffael Meyer
701896692a ci: set disabledLabels and context for greptile (#55883) 2026-06-13 18:46:03 +00:00
Raffael Meyer
93d6be2ed7 fix(Lead): stop storing Gravatar image URLs for Leads (#55880) 2026-06-13 19:03:29 +02:00
Rohit Waghchaure
b0e9ad198f feat: Allow to edit stock UOM qty for Stock Entry 2026-06-13 21:41:05 +05:30
Dipen Gala
a9029f83c7 feat(invoices): add tooltip description to Update Stock checkbox (#55868)
* feat(invoices): add tooltip description to Update Stock checkbox

Adds a description below the Update Stock checkbox on both Sales Invoice
and Purchase Invoice so users understand when to use the field without
consulting documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(invoices): replace Update Stock description with hover info tooltip

Removes the inline description text and adds an ℹ icon next to the
Update Stock checkbox label on both Sales Invoice and Purchase Invoice.
Hovering the icon shows the contextual tooltip via Bootstrap tooltip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(invoices): use Frappe native tooltip-content class for Update Stock icon

Replace Bootstrap .tooltip() (pure black bg) with Frappe's own
.tooltip-content CSS class so the hover tooltip matches the rest of
the ERPNext UI — uses var(--bg-dark-gray) and var(--text-dark).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(invoices): use frappe.ui.SidebarCard for Update Stock info tooltip

Replace custom CSS tooltip with the same SidebarCard + Popper approach
Frappe's InfoCard uses for field description tooltips — gives the native
ERPNext card appearance (white card, border, shadow) on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(invoices): use built-in field description for Update Stock tooltip

Replace custom SidebarCard JS tooltip with Frappe's native
description + show_description_on_click field property on the
update_stock field in Sales Invoice and Purchase Invoice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove duplicate description in purchase_invoice update_stock field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: restore custom tooltip in purchase_invoice.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: remove all changes from purchase_invoice.js

Keep purchase_invoice.js identical to upstream develop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:48:03 +05:30
rohitwaghchaure
31e4da562d Merge pull request #55874 from rohitwaghchaure/fixed-permission-for-bom-comparison-tool
fix: permission in bom compare tool
2026-06-13 19:10:38 +05:30
Rohit Waghchaure
e6fdb3702a fix: permission in bom compare tool 2026-06-13 19:09:04 +05:30
rohitwaghchaure
bd60a9be90 Merge pull request #55849 from rohitwaghchaure/fixed-permissions-for-whitelist-functions
fix: permission for whitelist functions
2026-06-13 18:36:46 +05:30
Rohit Waghchaure
a64466561f fix: permission for whitelist functions 2026-06-13 17:45:37 +05:30
Mihir Kandoi
f7ff25d9a8 Merge pull request #55835 from mihir-kandoi/codex/develop-user-disable-audit-fix
fix: sync employee user status after save
2026-06-13 14:49:34 +05:30
Dipen Gala
021b807057 refactor(stock-balance): reduce alt UOM to single column, fix i18n, add tests
- Reduce from 2 alternate UOM columns to 1 (first alt UOM by idx)
- Fix broken translation strings: replace _(f"...{slot}") with
  _("...") — f-strings inside _() are never extracted by bench
  get-untranslated, breaking non-English installations
- Simplify fieldnames: alt_uom_1/alt_uom_1_bal_qty → alt_uom/alt_uom_bal_qty
- Add 4 test cases covering: single alt UOM, no alt UOM, disabled filter,
  and multiple alt UOMs (first-wins behaviour)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:34:28 +05:30
Diptanil Saha
c933e34914 fix: opportunity creation from contact us page (#55841) 2026-06-13 04:47:45 +00:00
Mihir Kandoi
87092961e7 Merge pull request #55853 from SandraFrappe/fix/cost-center
fix: pass source cost center to target cost center
2026-06-12 20:57:04 +05:30
Umair Sayed
bc7c0de208 refactor(bom): remove qty=0 alert from BOM Secondary Item JS
The informational toast is not required for the feature to work.
The core fix (reqd removed from JSON, validation relaxed in bom.py)
is sufficient to allow zero qty on BOM secondary items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 16:10:56 +05:30
rohitwaghchaure
3f436985ed Merge pull request #55844 from rohitwaghchaure/fixed-job-card-permissions
fix: permissions in workstation file
2026-06-12 16:05:24 +05:30
Umair Sayed
de3df6bcef fix(manufacture): preserve user-entered rate for secondary items with zero cost allocation
When a BOM secondary item has cost_allocation_per = 0 (the default), the
previous code unconditionally computed `0 / transfer_qty = 0`, wiping any
rate the user had entered for the item. Now the allocation formula only runs
when cost_allocation_per > 0, allowing the valuation-rate fallback (or a
manually entered rate) to apply instead.

Additionally, secondary items with transfer_qty = 0 now short-circuit the
entire rate pipeline: they get rate = 0 and amount = 0 immediately, avoiding
a ZeroDivisionError and the spurious "enter basic rate" prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 16:04:12 +05:30
Rohit Waghchaure
cf127e8900 fix: permissions in workstation file 2026-06-12 15:37:46 +05:30
Umair Sayed
6771daf6a1 fix(bom): allow zero qty for secondary items (Co-Product, By-Product, Scrap, Additional Finished Good)
Secondary output items in a BOM do not always guarantee output during
manufacture. The actual qty is only known when manufacturing completes,
so setting zero in the BOM is a valid way to express "output is
non-deterministic".

Changes:
- Remove `reqd: 1` from the qty field in BOM Secondary Item so that 0
  is accepted as an explicit value (non_negative constraint is kept, so
  negative values are still rejected).
- Relax validate_secondary_items() in bom.py to only reject qty that is
  None/missing, not qty that is explicitly 0.
- Add a qty event handler in bom.js that shows a blue informational
  alert when the user sets qty to 0, explaining that the actual output
  will be recorded at manufacture time.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 14:45:02 +05:30
SandraFrappe
9ea766fc10 fix: pass source cost center to target cost center 2026-06-12 14:44:22 +05:30
Dipen Gala
2d93c5835a feat: add alternate UOM balance columns to Stock Balance report
Closes #52953

The Stock Balance report previously showed balance qty only in the item's
stock UOM. To view balance in an alternate UOM, users had to set the
"Include UOM" filter — which applies a single UOM to all items. This breaks
down when different items use different alternate UOMs (e.g., Pens in Box,
Ink in Milliliters).

This change adds a new "Show Alternate UOM Balance" checkbox filter. When
enabled, up to two alternate UOM columns are injected right after the
Balance Qty column:

  Balance Qty | Alt UOM 1 | Balance Qty (Alt UOM 1) | Alt UOM 2 | Balance Qty (Alt UOM 2)

Each row resolves its own alternate UOMs from `tabUOM Conversion Detail`
(ordered by idx, excluding the item's stock UOM). The converted balance
qty is computed as: stock qty / conversion_factor.

Items with fewer than 2 alternate UOMs leave the extra columns blank.
The existing "Include UOM" filter behaviour is unchanged.
2026-06-12 14:11:13 +05:30
rohitwaghchaure
53180fde93 Merge pull request #55845 from frappe/fix-update-stock-expense-head-warning
fix: remove unnecessary expense head warning for purchase invoices with update stock
2026-06-12 13:29:34 +05:30
Dipen Gala
224dff32df fix: remove unnecessary expense head warning for purchase invoices with update stock
When a Purchase Invoice is created with `update_stock = 1`, the system
automatically replaces the item's expense account with the correct
inventory account for perpetual inventory. This is expected behaviour,
but a `frappe.msgprint` warning was being shown to the user:

  "Expense Head changed to Stock In Hand because account Cost of Goods
   Sold is not linked to warehouse Stores or it is not the default
   inventory account."

The message is purely informational, provides no actionable guidance,
and confuses users who deliberately enable Update Stock. The underlying
account substitution logic is unchanged; only the popup is suppressed.

The two other `msgprint` calls (for the Purchase-Receipt-linked and
no-Purchase-Receipt flows) are intentionally preserved — those surface
a genuine change in behaviour that users may not expect.

Fixes: https://github.com/frappe/erpnext/issues/...
2026-06-12 12:57:58 +05:30
rohitwaghchaure
292bfa2a34 Merge pull request #55832 from rohitwaghchaure/fixed-regression-55827
fix: bom creator issue
2026-06-12 00:00:01 +05:30
Mihir Kandoi
e90896ced7 Merge pull request #55838 from aerele/fix/uom-mandatory
fix(stock): make uom mandatory in item uom table
2026-06-11 23:39:00 +05:30
Rohit Waghchaure
c360487cd1 fix: converted whitelist non class methods to class methods 2026-06-11 23:33:47 +05:30
pandiyan
a0177fdbe8 fix(stock): make uom mandatory in item uom table 2026-06-11 23:03:27 +05:30
Mihir Kandoi
64175bdb3e fix: skip unchanged employee user status sync 2026-06-11 21:34:43 +05:30
Mihir Kandoi
4fed04c6c7 fix: sync employee user status after save 2026-06-11 20:58:35 +05:30
Rohit Waghchaure
35fe9c60c7 fix: bom creator issue 2026-06-11 20:27:35 +05:30
Mihir Kandoi
878c22fa3f Merge pull request #55820 from aerele/fix/support-70979
fix: show user disable audit log
2026-06-11 20:27:01 +05:30
rohitwaghchaure
12ada21639 Merge pull request #55827 from rohitwaghchaure/fixed-bom-creator-security-issues
fix: multiple issues related to BOM Creator
2026-06-11 19:45:12 +05:30
Rohit Waghchaure
daf3f2e142 fix: multiple issues related to BOM Creator 2026-06-11 19:15:14 +05:30
S Sakthivel Murugan
d0f1239d2b fix(accounts): allow process statement of account generation with opening entries 2026-06-11 15:51:20 +05:30
rohitwaghchaure
ea3ec325e2 Merge pull request #55806 from rohitwaghchaure/refactor-stock-reservation
refactor: stock reservation feature
2026-06-11 13:53:23 +05:30
pandiyan
73d1852773 fix: show user disable audit log 2026-06-11 13:48:41 +05:30
Rohit Waghchaure
9c5f9218b5 refactor: stock reservation feature 2026-06-11 13:27:13 +05:30
ruthra kumar
a8a78a2163 Merge pull request #55695 from kaulith/fix/ar-report-respect-user-permissions
fix: apply user permissions to receivable/payable reports
2026-06-11 12:27:27 +05:30
Diptanil Saha
0b6121422d fix: added doctype filter validation for sales person wise transaction summary report (#55812) 2026-06-11 06:50:21 +00:00
Mohammad Umair Sayed
9249fa89aa 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>
2026-06-11 06:20:00 +00:00
Mihir Kandoi
5a816d19cb Merge pull request #55793 from mihir-kandoi/fix-bundle-dialog-lookup
fix(buying): resolve Get Items from Product Bundle by document name
2026-06-10 22:31:14 +05:30
Mihir Kandoi
a7d41f24a3 fix(stock): don't KeyError when neither bundle nor item_code is passed
row is a plain dict subclass, so row["item_code"] raised an unhandled
KeyError (500) when the payload had neither key. get_active_product_bundle
already returns None for falsy input, yielding an empty item list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:02:37 +05:30
Mihir Kandoi
81a1c2c8ce fix(stock): document-level permission check on the legacy bundle path
The legacy item_code path now resolves the active bundle's name via
get_active_product_bundle (same filters as the old joined query) so
frappe.has_permission can validate the specific document on both
branches. The orphaned get_product_bundle_items helper is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:51:49 +05:30
Mihir Kandoi
0c6f7fed55 fix(stock): permission check and test cleanup for bundle item fetch
The whitelisted get_items_from_product_bundle endpoint now verifies read
permission on Product Bundle (doc-level when a name is passed, doctype-
level for the legacy item_code path) so authenticated users can't
enumerate bundle components. The disabled-bundle test also restores the
disabled flag via addCleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:42:55 +05:30
Mihir Kandoi
bfee9df9aa fix: linter error 2026-06-10 18:53:32 +05:30
ravibharathi656
288f36bbd7 test: assert transaction currency and rate on payment entry gl entries 2026-06-10 16:41:39 +05:30
ravibharathi656
a3c9072812 fix: set transaction currency on payment entry gl entries 2026-06-10 16:41:26 +05:30
Mihir Kandoi
bddd1d0ebc fix(buying): resolve Get Items from Product Bundle by document name
Since Product Bundles became versioned, their names are PB-prefixed and
no longer double as the parent item code. The buying dialog kept passing
the picked bundle name as `item_code`, so the component lookup (which
filters `new_item_code`) matched nothing and the dialog silently added
no items.

The dialog now sends the selection as `product_bundle` and the endpoint
fetches that version's components by document name (rejecting
unsubmitted versions); passing `item_code` still resolves the parent
item's active version, preserving the legacy contract of the
whitelisted endpoint. The picker is also restricted to submitted
bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:40:12 +05:30
Nabin Hait
aa9f225c41 Merge pull request #55780 from nabinhait/refactor-je-services-internals
refactor(journal_entry): tidy the JE services and mapper internals
2026-06-10 11:54:04 +05:30
Mihir Kandoi
9c799f31ff Merge pull request #55791 from mihir-kandoi/product-bundle-disabled
feat(selling): allow disabling a Product Bundle
2026-06-10 11:42:25 +05:30
Mihir Kandoi
a60afaf91a Merge pull request #55789 from mihir-kandoi/fix-stock-ageing-unbuffered-cursor
fix: prefetch batchwise valuations before streaming SLEs in stock ageing
2026-06-10 11:37:23 +05:30
Nabin Hait
a4cff805f1 test(journal_entry): pin transaction-currency conversion in GL entries
Mutation testing on gl_composer surfaced that the foreign-row
debit/credit_in_transaction_currency conversion (amount / exchange_rate) was
unverified -- a / vs * bug survived. Assert those fields in test_multi_currency
and add a foreign-debit case so both conversion directions are now caught.
2026-06-10 11:23:19 +05:30
Nabin Hait
4f55071eda test(journal_entry): cover the untested mapper builders
Add characterization tests for get_payment_entry_against_order (the Sales/
Purchase Order advance path, previously untested) and make_inter_company_journal_entry
(previously fully uncovered).
2026-06-10 11:23:19 +05:30
Nabin Hait
43bb6c5a42 refactor(journal_entry): break up unlink_asset_reference and type/document asset service
Split AssetService.unlink_asset_reference into _is_depreciation_asset_row /
_reverse_asset_depreciation / _restore_scheduled_depreciation /
_restore_finance_book_value / _block_scrap_journal_cancel, and add return type
hints and docstrings across the service. Behaviour preserved (netted by the
asset suite).
2026-06-10 11:22:15 +05:30
Nabin Hait
34955380ee refactor(journal_entry): break up get_payment_entry and add types/docstrings to mapper
Split get_payment_entry into _reference_exchange_rate / _append_party_row /
_append_bank_row, and add return type hints and docstrings to all mapper
document builders. Behaviour preserved.
2026-06-10 11:22:15 +05:30
Nabin Hait
1714e13b39 refactor(journal_entry): tidy reference-validator and GL-composer services
Add return type hints and option-A docstrings to JournalEntryReferenceValidator,
and split JournalEntryGLComposer.compose into _set_transaction_currency and
_gl_row helpers. Behaviour preserved.
2026-06-10 11:22:15 +05:30
Nabin Hait
263c3e9dd4 Merge pull request #55779 from nabinhait/refactor-je-functions
refactor(journal_entry): smaller functions, Query Builder, type hints and docstrings
2026-06-10 11:20:05 +05:30
Mihir Kandoi
c97c2d1e02 test(selling): cover disabled Product Bundle behaviour
Resolution skips disabled bundles, transactions referencing a disabled
version are blocked, rows without an explicit version stop packing, and
the Item Where Used report surfaces the disabled flag on bundle rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:48 +05:30
Mihir Kandoi
cf37478870 feat(selling): allow disabling a Product Bundle
Un-deprecate the `disabled` checkbox: it is now editable (also after
submit) and parks a bundle version without ceding its active slot, so
re-enabling restores it without re-activation.

- `get_active_product_bundle` (the single resolution entry point) skips
  disabled bundles, so every consumer stops treating the item as a bundle
  while it is disabled
- the version pickers on transaction item rows and the buying "Get Items
  from Product Bundle" dialog filter out disabled bundles
- an explicitly selected disabled version blocks the transaction with a
  validation error instead of silently re-packing another version
- Product Bundle Balance report excludes disabled bundles
- list view indicator: Disabled (grey) / Active (green), falling back to
  docstatus for drafts, cancelled and inactive submitted versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:48 +05:30
Mihir Kandoi
060a5c4eeb 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>
2026-06-10 10:49:27 +05:30
Nabin Hait
3ad32f4030 Merge pull request #55274 from yash14023/fix/debit-note-prevent-update-stock
fix(accounts): prevent update_stock on Debit Notes
2026-06-10 10:44:20 +05:30
Diptanil Saha
dfc824ded6 fix(process statement of accounts): validate pdf_name and validate permission before triggering send_auto_email (#55781) 2026-06-09 18:52:28 +00:00
Nabin Hait
f099dbad35 refactor(journal_entry): give get_outstanding an explicit parameter list
Replace the single opaque `args` parameter of the whitelisted get_outstanding
with explicit named parameters (the supported interface), splitting the body
into _get_journal_entry_outstanding / _get_invoice_outstanding. The legacy
`args` payload is still accepted via kwargs for backward compatibility with
custom apps. Resolves the overusing-args semgrep finding.
2026-06-09 23:28:12 +05:30
Nabin Hait
cc8ce03232 test(journal_entry): cover write-off, balance and advance-unlink flows; drop dead code
Add characterization tests for the previously untested get_balance (difference
on a blank row), get_outstanding_invoices (write-off rows) and
unlink_advance_entry_reference (reference cleared on cancel). Remove the unused
get_average_exchange_rate, which has no callers in erpnext.
2026-06-09 23:07:03 +05:30
Nabin Hait
bcc1e73962 docs(journal_entry): add class and public-method docstrings
Add a class docstring plus docstrings for the lifecycle hooks and the public
API helpers (get_outstanding, get_against_jv, get_exchange_rate, etc.).
Self-evident one-line methods are intentionally left undocumented.
2026-06-09 22:44:19 +05:30
Nabin Hait
32d7250946 refactor(journal_entry): break up reporting, exchange-rate and balance methods
Decompose update_invoice_discounting, set_print_format_fields,
get_balance_for_periodic_accounting, set_exchange_rate, get_balance and
get_outstanding_invoices into focused per-row / row-building helpers (verb
prefixed, with docstrings). The nested closure in update_invoice_discounting
that ignored its row id is dropped. Behaviour preserved.
2026-06-09 22:40:35 +05:30
Nabin Hait
4c1cabb53e refactor(journal_entry): break up create_remarks and validate_against_jv
Split create_remarks into _cheque_remark / _reference_remark / _bill_remark
helpers, and validate_against_jv into _validate_jv_reference,
_validate_jv_reference_direction and _against_jv_entries. Add docstrings.
Behaviour preserved.
2026-06-09 22:30:51 +05:30
Nabin Hait
1105cb8ddf refactor(journal_entry): add missing type hints
Add return annotations to the module-level helpers and to make_gl_entries,
get_balance and set_total_amount, plus parameter types for set_total_amount
and make_gl_entries.
2026-06-09 22:24:44 +05:30
Nabin Hait
8bb4ffc6b1 refactor(journal_entry): replace raw SQL with Query Builder
Convert the five raw frappe.db.sql calls to Query Builder / ORM: the
against-JV lookup, the write-off invoice listing (get_values, now a single
query), the JV outstanding aggregate (get_outstanding), and the bill-no
lookup (get_value). Behaviour preserved.
2026-06-09 22:17:49 +05:30
Nabin Hait
dfd7cd0bae Merge pull request #55767 from nabinhait/refactor-je-extract-services
refactor(journal_entry): extract reference, asset and document-builder services
2026-06-09 22:08:55 +05:30
rohitwaghchaure
e083aa4c86 Merge pull request #55778 from rohitwaghchaure/fixed-github-55621-develop
fix: Stock Reservation blocks Subcontracting operation within the same work order
2026-06-09 21:06:10 +05:30
Rohit Waghchaure
c4fbc745db fix: Stock Reservation blocks Subcontracting operation within the same Work Order 2026-06-09 20:40:00 +05:30
Mihir Kandoi
2b6234f7af fix: handle multi-select stock ageing filters (#55774) 2026-06-09 13:58:17 +00:00
MochaMind
88b9911136 fix: sync translations from crowdin (#55638) 2026-06-09 18:05:38 +05:30
Lakshit Jain
360f52e636 fix(taxes): add category and add_deduct_tax fields to tax entries (#55753) 2026-06-09 18:02:33 +05:30
Mihir Kandoi
6201fefdfb fix: show inactive product bundles in item where used (#55769) 2026-06-09 12:27:54 +00:00
Lakshit Jain
08129ff71c fix: update round off account functions to accept document context for regional overrides (#55758) 2026-06-09 17:52:19 +05:30
rohitwaghchaure
5357634b70 Merge pull request #55765 from rohitwaghchaure/fixed-github-55621
fix: Stock Reservation blocks Subcontracting operation within the same work order
2026-06-09 17:43:47 +05:30
Rohit Waghchaure
20ba97aa7d fix: Stock Reservation blocks Subcontracting operation within the same Work Order 2026-06-09 17:15:56 +05:30
Nabin Hait
d90d4c29e1 refactor(journal_entry): move mapper re-export to the top import block 2026-06-09 16:59:27 +05:30
Nabin Hait
ddbd61b2a2 refactor(journal_entry): point erpnext imports at mapper, trim re-exports
Update erpnext's own importers (asset depreciation, invoice discounting and the
JE tests) to import the builders from mapper.py directly. Drop
make_inter_company_journal_entry and make_reverse_journal_entry from the
backward-compat re-export in journal_entry.py -- they are not part of the
custom-app call surface; only the payment-entry builders remain re-exported.
2026-06-09 16:59:27 +05:30
Nabin Hait
6a7c9f616e refactor(journal_entry): extract document builders into mapper.py
Move the Payment Entry / Journal Entry builders (get_payment_entry and its
against-order/against-invoice helpers, make_inter_company_journal_entry,
make_reverse_journal_entry) into mapper.py. The whitelisted builders are
re-exported from journal_entry.py so existing call paths -- including custom
apps -- keep working, and the erpnext client calls now point at the mapper
path. get_payment_entry imports the exchange-rate/bank-account helpers lazily
to avoid a circular import with the re-export.
2026-06-09 16:59:27 +05:30
Nabin Hait
a3194720b4 refactor(journal_entry): rename asset service to AssetService
Rename JournalEntryAssetLinkage -> AssetService and the file asset_linkage.py
-> asset_service.py.
2026-06-09 16:59:27 +05:30
Nabin Hait
7825ddf989 refactor(journal_entry): extract asset linkage into a service
Move the nine asset/depreciation coupling methods (depreciation-account
validation, asset value updates on depreciation and disposal, and the
unlink-on-cancel logic) out of the controller into a JournalEntryAssetLinkage
service under services/. Pure behaviour-preserving move, netted by the asset
suite (asset, asset_value_adjustment) plus the JE module.
2026-06-09 16:59:27 +05:30
Nabin Hait
e9b67ff682 refactor(journal_entry): extract reference validation into a service
Move validate_reference_doc and its helpers, plus validate_orders and
validate_invoices, out of the controller into a JournalEntryReferenceValidator
service under services/. Behaviour preserved; the per-reference totals stay on
the document. The order/invoice validators are split into <=15-line helpers.
2026-06-09 16:59:27 +05:30
Jatin3128
4c3aa9b4f3 feat(subscription): add refunded status, billing heatmap and billing UX (#55617)
* fix(subscription): bill on creation and keep status in sync with invoices

* feat(subscription): add refunded status, billing heatmap and billing UX
2026-06-09 16:43:24 +05:30
Nabin Hait
ca77145522 Merge pull request #55749 from nabinhait/refactor-je-validate-reference-doc
refactor(journal_entry): split validate_reference_doc into per-row methods
2026-06-09 16:13:45 +05:30
Nabin Hait
5753c23ccf refactor(journal_entry): clarify reference helper names
Rename three private helpers for intent and to drop an abbreviation:
_is_validatable_reference -> _has_party_reference,
_accumulate_reference -> _register_reference,
_reference_dr_or_cr -> _reference_amount_field.
2026-06-09 15:28:12 +05:30
rohitwaghchaure
a397e82278 Merge pull request #55760 from rohitwaghchaure/fixed-github-55756
fix: don't allow to submit job card with hold status
2026-06-09 14:29:53 +05:30
Rohit Waghchaure
9c23229cbf fix: don't allow to submit job card with hold status 2026-06-09 14:03:27 +05:30
Mihir Kandoi
08f6af867a feat: record and select Product Bundle version on transactions (#55738)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:15:20 +05:30
rohitwaghchaure
6988781f81 Merge pull request #55748 from rohitwaghchaure/fixed-suppoort-70455
fix: sql injection
2026-06-09 09:25:24 +05:30
Nabin Hait
49093b326e refactor(journal_entry): split validate_reference_doc into per-row methods
Extract the 100-line, CC-27 validate_reference_doc into a thin orchestrator
loop plus focused per-row private methods, and lift the inline reference
field map to a module constant. Behaviour preserved; complexity drops from
27 to 3 and no extracted function exceeds 15 lines.
2026-06-08 23:18:52 +05:30
Nabin Hait
9503dd0c7f test(journal_entry): characterize validate_reference_doc branches
Pin every branch of validate_reference_doc before refactoring: Sales Order
debit / Purchase Order credit rejection, non-existent reference handling,
Sales/Purchase Invoice and Order party mismatches, and population of the
reference_totals/types/accounts side effects.
2026-06-08 23:18:22 +05:30
Rohit Waghchaure
bd0acf4413 fix: sql injection 2026-06-08 23:10:33 +05:30
rohitwaghchaure
969cdf1b26 Merge pull request #55737 from rohitwaghchaure/fixed-security-issue-job-card
fix: allow specific methods to run
2026-06-08 19:46:59 +05:30
Rohit Waghchaure
8db1eb0d27 fix: allow specific methods to run 2026-06-08 16:06:16 +05:30
rohitwaghchaure
d146dc5435 Merge pull request #55724 from rohitwaghchaure/fixed-support-67770-3
fix: validate fg and materials qty in the disassemble entry
2026-06-08 16:04:29 +05:30
rohitwaghchaure
0ca38517f3 Merge pull request #55716 from rohitwaghchaure/fixed-support-67770-2
fix: do not allow to make changes in SABB after submit
2026-06-08 15:25:26 +05:30
ruthra kumar
5d1af7fc93 Merge pull request #55487 from Shllokkk/accounts-perm-fix
fix: add validations in accounts whitelisted methods
2026-06-08 15:15:37 +05:30
Ankush Menat
1fab935434 fix: only require read for hold
Support weird workflows.
2026-06-08 15:12:24 +05:30
ruthra kumar
d6ba0f0eca Merge pull request #55486 from Shllokkk/crm-create-customer-fix
Validations in CRM-api endpoints
2026-06-08 15:10:26 +05:30
Rohit Waghchaure
49164f41b1 fix: validate fg and materials qty in the disassemble entry 2026-06-08 15:06:43 +05:30
Rohit Waghchaure
e36426e235 fix: do not allow to make changes in SABB after submit 2026-06-08 14:59:07 +05:30
Ankush Menat
ba936eefab fix: Add authorization checks on internal functions (#55709) 2026-06-08 14:49:32 +05:30
Mihir Kandoi
5eb9461cfd fix: remove item name from update items dialog item code column (#55718)
Co-authored-by: Abdullah <frappe@LAPTOP-4E788RM4.localdomain>
2026-06-08 13:54:42 +05:30
Nabin Hait
e1e588e416 Merge pull request #55627 from Shllokkk/inact-cust-report
fix(inactive_customers): add allowlist for doctype filter and migrate…
2026-06-08 13:20:09 +05:30
Mihir Kandoi
00880eb657 fix: disallow BOM finished good item in secondary items table (#55710)
The FG item produced by a BOM should not also appear as a secondary
item (Co-Product/By-Product/Scrap/Additional Finished Good). When an
Additional Finished Good shared the main FG's item code, the resulting
Stock Entry ended up with two rows of the same item carrying different
valuation rates. Validate against it instead, exempting legacy rows so
migrated BOMs can still be re-saved.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:47:32 +00:00
Mihir Kandoi
ae6aef91bd feat: add item where used report (#55660) 2026-06-08 07:42:37 +00:00
Diptanil Saha
faf92b1368 fix(cheque_print_template): print format creation from cheque print template requires system manager (#55708) 2026-06-08 07:23:26 +00:00
Mihir Kandoi
a52c8fdaea feat: make Product Bundle submittable and versioned (#55702)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:19:42 +05:30
rohitwaghchaure
030e1a77e6 Merge pull request #55645 from aerele/fix/support-70407
fix: bypass project permission check when updating consumed material …
2026-06-08 12:06:20 +05:30
Pandiyan P
d2306b1b29 fix: restrict already invoiced qty in intercompany purchase invoice (#55639) 2026-06-08 11:59:11 +05:30
Nabin Hait
601f39dda7 test(inactive_customers): remove non-positive days test case 2026-06-08 11:55:32 +05:30
kaulith
047e4faa90 fix: update items respect workflow "Only Allow Edit For" role (#55662) 2026-06-08 11:53:12 +05:30
Nabin Hait
8d7edafc99 refactor(inactive_customers): rename sales alias to sales_doctype 2026-06-08 11:52:56 +05:30
Nabin Hait
8f15dd4d5d refactor(inactive_customers): use descriptive aliases and add tests
Rename single-letter query-builder aliases (C, DT) to readable names
(customer, sales) and add report tests covering the column contract,
validation guards, and the days-since-last-order threshold.
2026-06-08 11:45:34 +05:30
ruthra kumar
bf769a52c0 Merge pull request #55665 from Shllokkk/add-ac-ignore-permissions-fix
fix: drop ignore_permissions handling from add_ac
2026-06-08 11:44:23 +05:30
MochaMind
1e238678d8 chore: update POT file (#55692) 2026-06-07 23:28:33 +00:00
Jatin3128
bb36e956ac fix(subscription): bill on creation and keep status in sync with invoices (#55615) 2026-06-08 04:24:56 +05:30
Raffael Meyer
5641f37381 ci: add review comments on gettext files (#55699) 2026-06-07 22:11:45 +00:00
Nabin Hait
577a79471b Merge pull request #55688 from nabinhait/pi-services
refactor(accounts): extract Purchase Invoice services
2026-06-07 23:28:11 +05:30
Nabin Hait
c2e472b03c refactor(accounts): extract Purchase Invoice BillingStatusService
Move PR billing sync and provisional-entry cancellation into
accounts/doctype/purchase_invoice/services/billing_status.py:

- update_billing_status_in_pr, get_pr_details_billed_amt and
  cancel_provisional_entries move into the service (internal-only;
  on_submit/on_cancel and make_gl_entries repointed)
- the service imports the shared allocation helpers from
  purchase_receipt/services/billing_status.py (PR owns the shared
  buying billing logic)
- also repoints the validate_expense_account call in
  validate_for_repost missed in the ExpenseAccountService commit

No behaviour change.
2026-06-07 23:02:43 +05:30
Nabin Hait
e5f9698055 refactor(accounts): extract Purchase Invoice ExpenseAccountService
Move expense-account resolution into
accounts/doctype/purchase_invoice/services/expense_account.py:

- set_expense_account stays as a controller delegator (dispatched from
  accounts_controller.py) and force_set_against_expense_account stays
  (called by repost_accounting_ledger)
- validate_expense_account and set_against_expense_account move into the
  service; validate() repointed (the unused force kwarg on
  set_against_expense_account is dropped with the method)

Pre-existing raw SQL (SRBNB-booked-in-PR check) moved verbatim.
No behaviour change.
2026-06-07 23:02:43 +05:30
Nabin Hait
e45b027a22 Merge pull request #55687 from nabinhait/pr-services
refactor(stock): extract Purchase Receipt services
2026-06-07 22:40:31 +05:30
Nabin Hait
78cc06f127 Merge pull request #55101 from Abdeali099/fix-blank-cell-period-column
fix: handle blank rows in financial statement formatter
2026-06-07 22:27:50 +05:30
Nabin Hait
00646b7ed3 Merge pull request #54684 from AhmedAbokhatwa/profit-loss-report
fix(profit-loss-report): handle zero base values and prevent null% display
2026-06-07 22:25:57 +05:30
Kaushal Shriwas
58582cfa09 test: cover user permission scoping in receivable report 2026-06-07 22:20:46 +05:30
Nabin Hait
9267bd9eea Merge pull request #55686 from nabinhait/po-services
refactor(buying): extract Purchase Order services
2026-06-07 22:20:31 +05:30
Nabin Hait
298d3d9016 Merge pull request #55685 from nabinhait/dn-services
refactor(stock): extract Delivery Note services
2026-06-07 22:17:47 +05:30
Nabin Hait
a9f0ec83a4 Merge pull request #55684 from nabinhait/so-services
refactor(selling): extract Sales Order services
2026-06-07 22:16:24 +05:30
Kaushal Shriwas
1ef4978a86 fix: apply user permissions to receivable/payable reports 2026-06-07 21:43:49 +05:30
Nabin Hait
f33de37da0 refactor(stock): extract Purchase Receipt StockReservationService
Move stock reservation on PR submission into
stock/doctype/purchase_receipt/services/stock_reservation.py:

- reserve_stock, reserve_stock_for_sales_order,
  reserve_stock_for_production_plan and get_production_plan_references
  move into the service (internal-only; on_submit repointed)
- delegates to the Sales Order controller contract
  (create_stock_reservation_entries) and the shared StockReservation
  class

No behaviour change.
2026-06-07 09:55:15 +05:30
Nabin Hait
2a6d9be18a refactor(stock): extract Purchase Receipt ProvisionalAccountingService
Move provisional accounting for non-stock items into
stock/doctype/purchase_receipt/services/provisional_accounting.py:

- add_provisional_gl_entry stays as a controller delegator (called as a
  doc method by both the PR and PI GL composers)
- validate_provisional_expense_account moves into the service;
  validate() repointed

No behaviour change.
2026-06-07 09:54:28 +05:30
Nabin Hait
d1765e85aa refactor(stock): extract Purchase Receipt BillingStatusService
Move PR↔PI billed-amount allocation into
stock/doctype/purchase_receipt/services/billing_status.py. Purchase
Receipt owns the shared buying billing logic; Purchase Invoice imports
from the service module:

- update_billing_status stays as a controller delegator (called by
  Purchase Invoice flows and v13 patches)
- the module-function family moves verbatim:
  update_billed_amount_based_on_po, update_billing_percentage,
  get_billed_amount_against_pr/_po,
  get_purchase_receipts_against_po_details,
  get_billed_qty_amount_against_purchase_receipt/_order,
  adjust_incoming_rate_for_pr, get_item_wise_returned_qty
- imports repointed in purchase_invoice.py (top-level + lazy) and
  patches/v15_0/recalculate_amount_difference_field.py

No behaviour change.
2026-06-07 09:53:37 +05:30
Nabin Hait
3df8e7bfe6 refactor(buying): extract Purchase Order StatusService
Move status transitions and receiving progress into
buying/doctype/purchase_order/services/status.py:

- update_status (module-level whitelisted wrapper + list view) and
  update_receiving_percentage (called by child_item_update) stay as
  controller delegators
- check_modified_date moves into the service (internal to update_status)

No behaviour change.
2026-06-07 09:46:28 +05:30
Nabin Hait
f7460f7be3 refactor(buying): extract Purchase Order DropShipService
Move drop-ship item handling into
buying/doctype/purchase_order/services/drop_ship.py:

- update_dropship_received_qty stays as a whitelisted controller
  delegator (called from purchase_order.js)
- update_delivered_qty_in_sales_order, has_drop_ship_item and
  set_received_qty_to_zero_for_drop_ship_items move into the service
  (internal-only; on_cancel and the module-level update_status wrapper
  repointed)

No behaviour change.
2026-06-07 09:45:38 +05:30
Nabin Hait
920abdc0e2 refactor(buying): extract Purchase Order SubcontractingService
Move subcontracting integration into
buying/doctype/purchase_order/services/subcontracting.py:

- set_service_items_for_finished_goods (called by production plan
  work-order planning) and can_update_items (onload + child_item_update)
  stay as controller delegators
- validate_fg_item_for_subcontracting, auto_create_subcontracting_order
  and update_subcontracting_order_status move into the service;
  validate(), on_submit and update_status repointed

No behaviour change.
2026-06-07 09:44:59 +05:30
Nabin Hait
e0e3dcc8bf refactor(stock): extract Delivery Note PackingService
Move packing slip / product bundle handling into
stock/doctype/delivery_note/services/packing.py:

- validate_packed_qty stays as a controller delegator (called via
  hasattr contract in accounts/utils.py); has_unpacked_items stays
  for onload/JS
- get_product_bundle_list and cancel_packing_slips move into the
  service (internal-only; on_cancel repointed)

Pre-existing raw SQL in cancel_packing_slips moved verbatim.
No behaviour change.
2026-06-07 09:34:21 +05:30
Nabin Hait
9d020365e0 refactor(stock): extract Delivery Note BillingStatusService
Move billing status tracking and return invoicing into
stock/doctype/delivery_note/services/billing_status.py:

- update_status and update_billing_status stay as controller delegators
  (whitelisted update_delivery_note_status wrapper, v13 patches and
  Sales Invoice call them)
- make_return_invoice moves into the service (internal to on_submit)
- the update_billed_amount_based_on_so module function moves to the
  service module; the sales_invoice.py import is repointed
- drops stale Document/DocType/Abs imports

Pre-existing raw SQL in update_billed_amount_based_on_so moved verbatim.
No behaviour change.
2026-06-07 09:33:57 +05:30
Nabin Hait
0f876c10aa refactor(selling): extract Sales Order SubcontractingService
Move subcontracting (inward) integration into
selling/doctype/sales_order/services/subcontracting.py:

- can_update_items stays as a controller delegator (onload data +
  child_item_update.py caller)
- validate_fg_item_for_subcontracting and
  update_subcontracting_order_status move into the service; validate()
  and StatusService.update_status repointed

No behaviour change.
2026-06-06 23:13:52 +05:30
Nabin Hait
7f3ddfb3a1 refactor(selling): extract Sales Order DeliveryScheduleService
Move delivery schedule management into
selling/doctype/sales_order/services/delivery_schedule.py:

- get_delivery_schedule and create_delivery_schedule stay as whitelisted
  controller delegators (called from sales_order.js)
- update_delivery_date_based_on_schedule, delete_delivery_schedule_items
  and delete_removed_delivery_schedule_items move into the service
  (internal-only; on_submit/on_cancel repointed)

No behaviour change.
2026-06-06 23:13:12 +05:30
Nabin Hait
268d98d5f7 refactor(selling): extract Sales Order StatusService
Move status computation and progress tracking into
selling/doctype/sales_order/services/status.py:

- update_status, update_delivery_status, update_picking_status and
  set_indicator stay as controller delegators (whitelisted wrapper,
  purchase_order/pick_list/child_item_update callers, portal contract)
- check_modified_date moves into the service (internal to update_status)
- the billing/delivery/advance status defaults in validate() move to
  StatusService.set_default_statuses()

No behaviour change.
2026-06-06 23:12:28 +05:30
Nabin Hait
1be84112a7 refactor(selling): extract Sales Order StockReservationService
Move stock reservation logic out of the Sales Order controller into
selling/doctype/sales_order/services/stock_reservation.py:

- validate_reserved_stock, enable_auto_reserve_stock and the
  get_unreserved_qty module function move into the service (internal-only,
  callers repointed; stock_reservation_entry.py import updated)
- has_unreserved_stock, create/cancel_stock_reservation_entries and
  update_reserved_qty stay on the controller as thin delegators
  (whitelisted/JS-reachable or called from selling_controller and
  child_item_update)

No behaviour change.
2026-06-06 23:08:03 +05:30
Nabin Hait
fcff212eec Merge pull request #55679 from nabinhait/email-options-in-appointment
fix: set options Email for customer_email field in appointment
2026-06-06 21:11:53 +05:30
Nabin Hait
9b1157c914 fix: set options Email for customer_email field in appointment 2026-06-06 20:41:16 +05:30
Diptanil Saha
0ba2961103 fix: updated role based permission for terms and conditions doctype (#55674) 2026-06-06 11:44:08 +00:00
Shllokkk
37d2adc74b fix: drop ignore_permissions handling from add_ac 2026-06-05 20:49:17 +05:30
rohitwaghchaure
859d4caae4 Merge pull request #55661 from rohitwaghchaure/fixed-naming-series-issue
fix: naming series issue
2026-06-05 20:43:15 +05:30
Rohit Waghchaure
3a50056968 fix: naming series issue 2026-06-05 18:52:18 +05:30
rohitwaghchaure
e1f6bb70bc Merge pull request #55651 from rohitwaghchaure/fixed-rename-files
refactor: rename files
2026-06-05 15:56:37 +05:30
Nabin Hait
734fe874f2 Merge pull request #55647 from nabinhait/stock-controller-refactoring
refactor(stock): extract StockController into focused services
2026-06-05 15:52:00 +05:30
Khushi Rawat
5aab5502f0 feat: add side-by-side defaults comparison view in item defaults grid (#55017)
* feat: add side-by-side defaults comparison view in item defaults grid

* fix: coderabbit suggested changes

* fix: change label of the fields

* fix: description design
2026-06-05 15:43:57 +05:30
Khushi Rawat
5873f55cf0 feat: item prices list view (#54853)
* feat: add item prices tab to Item doctype

* feat: item form pricing tab

* fix: remove action button for edit item price

* fix: prevent stale item price rendering after form navigation

* fix: remove stale call to deleted edit_prices_button function

* fix: item price list fixes

* fix: show filtered price list

* fix: show filtered price list
2026-06-05 15:42:18 +05:30
Mihir Kandoi
df03524b19 [codex] Show in-transit status for add-to-transit Stock Entries (#55644) 2026-06-05 10:08:22 +00:00
Rohit Waghchaure
18dbc7887b refactor: rename files 2026-06-05 15:29:41 +05:30
Diptanil Saha
7c6b13a838 chore: remove unused whitelisted method from project (#55648) 2026-06-05 09:52:58 +00:00
Nabin Hait
7d72d21bbe refactor(stock): add _service suffix to serial_batch_bundle and quality_inspection modules
Consistent service-module naming: serial_batch_bundle_service.py /
quality_inspection_service.py (matching stock_ledger_service.py). Importers updated;
engine-module imports (stock.serial_batch_bundle) untouched.
2026-06-05 15:16:41 +05:30
rohitwaghchaure
62fdc4c457 Merge pull request #55646 from rohitwaghchaure/fixed-fields-issue
fix: positional argument issue
2026-06-05 15:10:41 +05:30
Nabin Hait
b41eb6876a refactor(stock): rename stock_ledger service module to stock_ledger_service
Avoids basename collision with the core SLE engine erpnext/stock/stock_ledger.py
(the service even imports make_sl_entries from it). File now maps 1:1 to its class,
StockLedgerService.
2026-06-05 14:59:41 +05:30
Nabin Hait
9bb71e5ec4 chore(stock): remove ledger characterization scaffolding
Phase 0 golden-master safety net for the stock_controller refactor. It served its
purpose (every extraction verified byte-identical GL + Stock Ledger output) and is
removed before shipping, mirroring the earlier GL characterization cleanup.
2026-06-05 14:45:09 +05:30
Nabin Hait
c5ff1009b2 refactor: relocate ledger_preview to controllers (cross-cutting, not stock-only)
The preview feature serves both accounts and stock vouchers (SI/PI/PE + DN/PR/SE)
and its show_*_preview entry points live in controllers/stock_controller, so the
cohesive GL+SLE preview module belongs in controllers/, not stock/services/. Pure
move + import-path update; GL and stock previews stay together (shared get_columns/
get_data formatters; read-side, kept out of the write-path services).

Verified: ledger snapshots green; module resolves at new path.
2026-06-05 14:41:58 +05:30
Rohit Waghchaure
ff2b9a99e7 fix: missing fields issue 2026-06-05 14:41:42 +05:30
Nabin Hait
b82b2c2ebd refactor(stock): use central erpnext/exceptions.py for stock exceptions
Merge the stock exceptions into the existing app-wide erpnext/exceptions.py (under a
'# stock' section) instead of a separate erpnext/stock/exceptions.py, matching the
established convention. stock_controller still re-exports them for backward
compatibility; services import from erpnext.exceptions.

Verified: ledger snapshots, quality inspection suite, stock_entry batch-expiry stay green.
2026-06-05 14:34:27 +05:30
Shllokkk
5dbf3fdde0 fix: add permission checks in accounts whitelisted methods 2026-06-05 13:52:57 +05:30
pandiyan
4b0b7adeee fix: bypass project permission check when updating consumed material cost 2026-06-05 13:35:40 +05:30
Nabin Hait
8db05fc4da refactor(stock): drop 7 in-repo-only StockController delegators
Remove the delegators whose only callers were in-repo StockController subclasses,
repointing every caller to the owning service / free function:

- validate_warehouse_of_sabb, validate_duplicate_serial_and_batch_bundle,
  validate_serialized_batch, clean_serial_nos -> SerialBatchBundleService
- update_inventory_dimensions -> StockLedgerService
- validate_putaway_capacity -> putaway_rule.validate_putaway_capacity (free fn)
- set_landed_cost_voucher_amount -> landed_cost_voucher.set_landed_cost_voucher_amount

Callers repointed: StockController.validate() (base), StockEntry.validate(),
StockReconciliation (validate + reconciliation SLE build), BuyingController.validate(),
and the Landed Cost Voucher submit (doc.set_landed_cost_voucher_amount on the receipt).

Verified green: ledger snapshots, stock_entry (91), stock_reconciliation (34),
landed_cost_voucher (15), subcontracting_receipt (32), delivery_note (71).
2026-06-05 13:32:04 +05:30
Nabin Hait
6a064765d1 refactor(stock): drop zero-caller StockController delegators
Re-audited the kept delegators for true external callers. Two had none:
- has_landed_cost_amount: no caller anywhere (the landed_cost_voucher.py free
  function is what the composers use) — pure dead delegator, removed.
- validate_internal_transfer: only StockController.validate() called it; inline that
  one hook to StockInternalTransferService(self).validate_internal_transfer() and
  remove the delegator.

All other kept delegators have real external/subclass/run_method callers and remain
as the stock extension contract.

Verified: ledger snapshots + DN/PR internal-transfer suites stay green.
2026-06-05 12:46:09 +05:30
Nabin Hait
78d5fbaca4 refactor(stock): address layering/robustness review findings (#8, #9, #10)
#8 ledger_preview: wrap the submit-in-memory dry run in a savepoint inside
get_accounting_ledger_preview / get_stock_ledger_preview and roll back to it in a
finally, so the preview never persists entries regardless of caller (previously
only the whitelisted show_*_preview wrappers' full rollback made it safe).

#9 exceptions: move BatchExpiredError and the QualityInspection* errors into a new
erpnext/stock/exceptions.py and re-export them from stock_controller for backward
compatibility (job_card and tests still import from the controller; identity is
preserved). Services now import from the neutral module instead of back from the
controller they were extracted out of.

#10 quality inspection: extract the duplicated doctype->inspection-field map into a
single INSPECTION_FIELDNAME_MAP constant in the service, consumed by both
validate_inspection and check_item_quality_inspection.

Verified: ledger snapshots, quality inspection suite, stock_entry batch-expiry test
stay green; preview smoke-tested to persist nothing and not roll back the caller.
2026-06-05 12:29:55 +05:30
Nabin Hait
3dba21f814 refactor(stock): pass inter_company_reference as an argument
validate_internal_transfer_qty stashed the value on a name-mangled instance
attribute (self.__inter_company_reference) that get_item_wise_inter_transfer_qty
read back, creating an implicit call-ordering contract: calling the latter on a
fresh service without the former first raised AttributeError. Compute it as a local
and pass it as a method argument, removing the hidden cross-method state.

Verified: ledger snapshots + PR internal-transfer suite stay green.
2026-06-05 12:19:52 +05:30
Nabin Hait
f4705fd5a8 refactor(stock): remove dead get_serialized_items method
get_serialized_items had zero callers anywhere (Python, JS, run_method) on develop
and after the refactor; it was relocated into SerialBatchBundleService by mistake
instead of being dropped. Delete it — also removes a raw frappe.db.sql_list query
that duplicated the ORM helper get_serial_or_batch_items.
2026-06-05 12:17:54 +05:30
Nabin Hait
f1f66bdf2f perf(stock): cache is_serial_batch_item via Item document cache
The @frappe.request_cache decorator keyed on `self`, which after the service
extraction is a transient SerialBatchBundleService built per delegated call, so the
request-wide dedup was lost and dead instances were pinned in request_cache. Use
frappe.get_cached_value on the Item instead: caching is keyed by the item (request-
local + redis), effective regardless of service-instance churn, and the redundant
frappe.db.exists query is dropped.

Verified: ledger snapshots + serial and batch bundle suite stay green.
2026-06-05 12:12:36 +05:30
Nabin Hait
a02ef40a5b test(stock): harden ledger characterization harness
Address code-review findings on the Phase-0 safety net:
- Per-test savepoint/rollback isolation so cumulative SLE fields
  (qty_after_transaction, stock_value, valuation_rate) are deterministic
  regardless of test order or leftover state (were order-coupled before).
- Backdate prerequisite stock to PREREQUISITE_DATE so balances are positive and
  independent of the wall-clock date.
- Capture has_serial_and_batch_bundle (boolean linkage, not the volatile docname)
  so a dropped serial/batch bundle link is caught.
- Add pr_batch_item and pr_serial_item scenarios to exercise SerialBatchBundleService
  (the largest extraction, previously uncovered).

Goldens regenerated. Verified deterministic across repeated assert runs.
2026-06-05 11:44:25 +05:30
Nabin Hait
1a4b61a822 fix(stock): skip disabled Putaway Rules in capacity validation
validate_putaway_capacity selected the 'disable' field but checked rule.get('disabled')
(always None), so disabled rules still enforced capacity and could wrongly raise
'Over Receipt'. Use the correct 'disable' key. Pre-existing bug surfaced during the
stock_controller refactor review.
2026-06-05 11:44:22 +05:30
Mihir Kandoi
34a0aa2ee9 fix: work order status should be in process if material transfer is s… (#55641) 2026-06-05 05:34:43 +00:00
Shllokkk
b5a84c5e65 fix: add validation and tests for set_status 2026-06-05 02:56:01 +05:30
Mihir Kandoi
e2a1f6057d feat: show non stock items and secondary items in work order (#55631)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:43:47 +05:30
Pandiyan P
34d128d752 fix: prevent selling items from sample retention warehouse (#55613)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-06-04 16:30:57 +00:00
Mihir Kandoi
d6a201ed4a feat: create sales invoice from pick list (#55594)
* feat: create sales invoice from pick list

* test: cover sales invoice creation from pick list

* fix: require update stock for pick list invoices

* fix: return SI should not bother with pick list
2026-06-04 15:49:19 +00:00
rohitwaghchaure
0a07fb3a4e Merge pull request #55625 from rohitwaghchaure/fixed-job-card-refactor
refactor: job card py file
2026-06-04 19:45:08 +05:30
Shllokkk
9cecf2e6f9 refactor: convert rfq_transaction_list to query builder (#55497) 2026-06-04 19:36:40 +05:30
Nabin Hait
d1fd91a542 refactor(stock): extract ledger preview helpers to ledger_preview module
Move the read-side GL/SLE preview helpers (get_accounting_ledger_preview,
get_stock_ledger_preview, get_sl_entries_for_preview, get_gl_entries_for_preview,
get_columns, get_data) into erpnext/stock/services/ledger_preview.py. The
whitelisted show_accounting_ledger_preview / show_stock_ledger_preview entry points
stay in stock_controller (client JS hardcodes their dotted path) and call the
relocated helpers.

Behaviour-preserving: ledger characterization snapshots stay green.
2026-06-04 16:51:17 +05:30
Nabin Hait
8e41e75d89 refactor(stock): relocate landed-cost and putaway logic to owning doctypes
These clusters are really other doctypes' logic parked on StockController, so they
move next to the doctype that owns them rather than into a stock service:

- set_landed_cost_voucher_amount / get_item_account_wise_lcv_entries /
  has_landed_cost_amount -> landed_cost_voucher.py (free functions). Controller keeps
  thin delegators (called as doc.X from 4 GL composers, buying_controller and the LCV
  doctype).
- validate_putaway_capacity -> putaway_rule.py (free function, next to
  get_available_putaway_capacity it already used). Controller keeps a delegator
  (validate hook + Stock Entry/Reconciliation); prepare_over_receipt_message becomes a
  private helper there.

Drops now-unused Sum/defaultdict imports from stock_controller.

Behaviour-preserving: ledger snapshots, putaway and landed-cost suites stay green.
2026-06-04 16:22:38 +05:30
Nabin Hait
7c2406077a refactor(stock): extract StockInternalTransferService from StockController
Move internal-transfer warehouse/currency/packed-item/over-receipt-qty validation
into erpnext/stock/services/internal_transfer.py as a delegating service. This is
the stock-side counterpart to accounts/services/internal_transfer.py (party/rate/
pricing). validate_internal_transfer keeps a controller delegator (validate hook);
the other 7 methods are internal-only. The is_internal_transfer() predicate is
already consolidated on AccountsController.

Behaviour-preserving: ledger snapshots + DN/PR internal-transfer suites stay green.
2026-06-04 16:05:42 +05:30
Nabin Hait
926bdf5a20 refactor(stock): extract QualityInspectionService from StockController
Move quality-inspection validation (validate_inspection + validate_qi_presence/
submission/rejection) into erpnext/stock/services/quality_inspection.py as a
delegating service. validate_inspection keeps a controller delegator (called from
validate() and 3 other doctypes); the three row-level helpers are internal-only.
The whitelisted module fns check_item_quality_inspection / make_quality_inspections
stay in stock_controller (stable endpoint paths).

Behaviour-preserving: ledger snapshots + quality inspection suite stay green.
2026-06-04 15:54:34 +05:30
Nabin Hait
b447cbc3c1 refactor(stock): move GL-building helpers onto BaseStockGLComposer
Relocate get_voucher_details, check_expense_account and get_debit_field_precision
from StockController to BaseStockGLComposer, where they are only used (by compose()
and AssetCapitalizationGLComposer). Call sites flipped from doc.X to self.X.

Inventory-account resolution (get_inventory_account_map/_dict, etc.) stays on the
controller: it is a doc-contract method called as doc.X from non-stock-composer code
(PI controller/composer, accounts/utils, repost_accounting_ledger), so it cannot fold
into BaseStockGLComposer. make_gl_entries / make_gl_entries_on_cancel / add_gl_entry
likewise stay (contract entry points).

Behaviour-preserving: ledger snapshots, subcontracting receipt and asset
capitalization suites stay green.
2026-06-04 15:50:32 +05:30
Nabin Hait
4affdd51f6 refactor(stock): extract StockLedgerService from StockController
Move SLE building and reposting (get_sl_entries, update_inventory_dimensions,
get_stock_ledger_details, get_items_and_warehouses, make_sl_entries,
repost_future_sle_and_gle) into erpnext/stock/services/stock_ledger.py as a
delegating service. All six keep thin controller delegators (each has external
callers). The repost helper *functions* stay module-level in stock_controller
(imported widely); the service calls them. Also drop import orphaned by this and
the prior bundle extraction.

Behaviour-preserving: ledger characterization snapshots and the repost item
valuation suite stay green.
2026-06-04 15:35:04 +05:30
Nabin Hait
a26d8d448c refactor(stock): extract SerialBatchBundleService from StockController
Move serial & batch bundle handling (creation, validation, return bundles,
teardown) out of StockController into erpnext/stock/services/serial_batch_bundle.py
as a delegating service. The controller keeps thin delegators for the 10 methods
reached from other doctypes or run_method; the 12 internal-only helpers live in
the service. make_bundle_for_material_transfer stays a module fn in stock_controller
(imported by stock/serial_batch_bundle.py).

Behaviour-preserving: ledger characterization snapshots and the full Serial and
Batch Bundle test suite stay green.
2026-06-04 15:00:44 +05:30
Shllokkk
8de259a669 Merge branch 'develop' into inact-cust-report 2026-06-04 14:57:58 +05:30
Shllokkk
2ecf8b0466 fix(inactive_customers): add allowlist for doctype filter and migrate to qb 2026-06-04 14:55:49 +05:30
Nabin Hait
700a7fdad3 test(stock): add ledger characterization snapshots
Phase 0 safety net for the stock_controller service refactor. Captures the
combined GL + Stock Ledger output of representative stock vouchers (DN, Stock
Entry, Stock Reconciliation, Purchase Receipt incl. returns/taxes) as golden
snapshots, so later phases can prove ledger behaviour stays byte-identical while
stock_controller is split into services.

Run: bench --site <site> run-tests --module erpnext.stock.test_ledger_characterization
Regenerate goldens: REGEN_LEDGER_SNAPSHOTS=1 (after intentional changes only).
2026-06-04 14:45:21 +05:30
Rohit Waghchaure
ca310693ff refactor: job card codebase 2026-06-04 13:29:19 +05:30
ruthra kumar
e842812ba5 Merge pull request #55536 from frappe/fix-quotation-to-crm-deal
fix: allow CRM Deal as Quotation To for CRM integration
2026-06-04 11:51:48 +05:30
rohitwaghchaure
5289752c5f Merge pull request #55596 from rohitwaghchaure/refactor-manufacturing-related-files
refactor: split manufacturing related files into mapper + services modules
2026-06-04 00:40:35 +05:30
Rohit Waghchaure
3757544359 refactor: split manufacturing related files into mapper + services modules 2026-06-04 00:16:37 +05:30
Nabin Hait
51fee2d602 Merge pull request #55327 from nabinhait/erpnext-refactoring
refactor: ERPNext file structure refactoring [WIP]
2026-06-03 21:53:18 +05:30
Jatin3128
d54db2e0ca fix(subscription): correct billing/deferred bugs and tighten guards (#55554) 2026-06-03 21:26:27 +05:30
Antoine Maas
cb84678198 fix: duplicating a Customer/Supplier shouldn't inherit the source's primary contact and address (#55421)
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
2026-06-03 21:04:12 +05:30
Pandiyan P
40bcf6e3b6 fix(selling): consider delivered qty (#55597) 2026-06-03 21:03:33 +05:30
Nikhil Kothari
3294490040 feat(banking): PDF statement importer and overriding column mapping (#55559)
* feat(banking): PDF statement importer

* feat(banking): allow users to override column mapping

* fix: store pending page images in flags
2026-06-03 19:48:14 +05:30
Khushi Rawat
855eeb1078 Merge pull request #54983 from Shllokkk/standard-letter-heads
feat: Standard letter heads for DocTypes and Reports
2026-06-03 18:22:52 +05:30
Nabin Hait
ef8cc166c1 ci: move nosemgrep to def line for asset_repair.on_cancel
frappe-modifying-but-not-comitting anchors on the method definition, so the
suppression must sit on the def line (matching the convention used elsewhere
in the codebase); inner-line comments did not suppress it.
2026-06-03 17:57:24 +05:30
Nishka Gosalia
3c5cb8d579 Merge pull request #55470 from nishkagosalia/accounts-settings-cleanup
fix(UX): Accounts settings cleanup
2026-06-03 17:54:48 +05:30
Nabin Hait
5adeca44da fix: linter issue 2026-06-03 17:45:47 +05:30
Nikhil Kothari
371b5c7593 fix: spelling of Payment Reconciliation in sidebar (#55599) 2026-06-03 12:11:07 +00:00
Nabin Hait
c271826130 chore(accounts): remove GL characterization scaffolding
The golden-master snapshots, capture harness, characterization test, and
refactor spec existed to prove the accounts refactor preserved GL output.
All 29 characterization tests pass against the merged code, so the
scaffolding has served its purpose and is removed before merge.

Removes:
- erpnext/accounts/gl_snapshot.py
- erpnext/accounts/gl_snapshots/ (29 snapshots)
- erpnext/accounts/test_gl_characterization.py
- specs/accounts_refactor_spec.md
2026-06-03 16:43:33 +05:30
Nabin Hait
4c6f33000b ci: silence false-positive semgrep findings on relocated code
Both patterns are unchanged from develop but newly appear in the diff
because the refactoring relocated them:

- purchase_order/mapper.make_purchase_invoice_from_portal: portal flow
  needs commit before redirect (matches develop behaviour)
- asset_repair.on_cancel: ignore_linked_doctypes is a runtime cancel flag,
  not a persisted field
2026-06-03 16:23:30 +05:30
Nishka Gosalia
635d291b62 Merge pull request #55309 from nishkagosalia/stock-settings-form-cleanup
fix(UX):stock settings form cleanup
2026-06-03 16:04:08 +05:30
Nabin Hait
092d8f771c fix: update references to relocated mapper functions and POS wrapper
After moving mapping functions into per-doctype mapper.py modules and POS
logic into POSService, several call sites still referenced the old
locations, breaking import/collection in CI:

- bulk_transaction: import mapper modules for make_* transitions
- test_purchase_order / test_purchase_receipt / test_stock_entry: import
  make_purchase_receipt, make_purchase_invoice, make_inter_company_purchase_receipt
  and make_stock_entry from their mapper modules
- order.html: point portal API URL to purchase_order.mapper
- sales_invoice: add validate_full_payment delegating wrapper (called by POSInvoice)
2026-06-03 15:50:06 +05:30
Mihir Kandoi
4ee8bbb06b refactor: minor problems in production plan (#55577) 2026-06-03 15:21:59 +05:30
Nabin Hait
53dfef8030 Merge branch 'develop' of https://github.com/frappe/erpnext into erpnext-refactoring 2026-06-03 15:20:49 +05:30
Loic Oberle
d2d28c9e03 refactor(accounting): replace sql with qb in diverse accounting-related files (#55416) 2026-06-03 15:19:24 +05:30
Nishka Gosalia
8b916b40ee Merge pull request #55591 from nishkagosalia/st-70386
fix: item report view
2026-06-03 15:05:08 +05:30
nishkagosalia
bca917380d fix: item report view 2026-06-03 14:54:40 +05:30
Khushi Rawat
64a3be8163 fix: only fetch enabled letterheads 2026-06-03 14:14:46 +05:30
Khushi Rawat
3337b47182 Merge branch 'develop' into standard-letter-heads 2026-06-03 14:11:35 +05:30
Nabin Hait
dfe3280737 Merge remote-tracking branch 'upstream/develop' into erpnext-refactoring
# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
#	erpnext/accounts/doctype/sales_invoice/sales_invoice.py
#	erpnext/buying/doctype/purchase_order/purchase_order.py
#	erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
#	erpnext/controllers/accounts_controller.py
#	erpnext/selling/doctype/sales_order/sales_order.py
#	erpnext/selling/doctype/sales_order/test_sales_order.py
#	erpnext/stock/doctype/delivery_note/delivery_note.py
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
#	erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
2026-06-03 13:23:03 +05:30
nishkagosalia
8a8b89e5dd fix(UX): Accounts settings cleanup 2026-06-03 13:13:39 +05:30
Shllokkk
a75693a81f fix: minor fixes in report print formats (#55151) 2026-06-03 13:13:04 +05:30
Pandiyan P
d0d9411700 fix(accounts): include asset items in purchase receipt validation (#55150) 2026-06-03 13:11:50 +05:30
Pandiyan P
c4d28a2612 fix(stock): set stock received but not billed account for purchase (#55149) 2026-06-03 13:10:26 +05:30
Abdeali Chharchhodawala
6c46692cc4 fix: add custom dimensions filters in Gross and Net profit report (#55110) 2026-06-03 13:08:45 +05:30
Antoine Maas
68b8ba7235 regional(setup): add 0% and 6% VAT rates for Belgium (#54719) 2026-06-03 13:05:13 +05:30
Nabin Hait
e0c285e27e refactor(gl): move make_discount_gl_entries onto SalesInvoiceGLComposer
It is Sales-Invoice-specific GL assembly and was the only TaxService
method called by the composer. Move it to SalesInvoiceGLComposer (verbatim),
call it as self.make_discount_gl_entries, drop the now-unused composer-level
TaxService local and the orphaned get_account_currency import in taxes.py.
2026-06-03 13:00:50 +05:30
Ankush Menat
b72cde73ba fix: Add likely missing escaps (#55574) 2026-06-03 07:28:05 +00:00
Lakshit Jain
260cec3b86 fix: prevent leakage of party-derived fields in cross doctype transactions (#55336)
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
2026-06-03 07:26:37 +00:00
Nabin Hait
cfed16ab6c refactor(gl): give SI and PI their own precision-loss GL entry method
Remove the doctype-branching make_precision_loss_gl_entry from
exchange_gain_loss.py (and its accounts_controller wrapper); add a
dedicated method to each of SalesInvoiceGLComposer and
PurchaseInvoiceGLComposer. The SI variant now passes 'Sales Invoice'
as the round-off voucher type (output-equivalent) and the throwaway
return value no longer shadows the gettext _ helper.
2026-06-03 12:45:58 +05:30
Nabin Hait
d8760b76a8 refactor(sales_invoice): drop loyalty delegation shims, call LoyaltyService directly
The make_/delete_/apply_loyalty_points methods on SalesInvoice only existed
as an inheritance surface for POSInvoice (self.X()). Route all callers
through LoyaltyService(doc).X() directly, consistent with how related-doc
cases already worked, and remove the three forwarding methods.
2026-06-03 12:40:46 +05:30
nishkagosalia
0b4e20ae98 fix(UX): stock settings form cleanup 2026-06-03 12:38:32 +05:30
Loic Oberle
a2a2e1020b refactor(sales_invoice): replace sql with qb in get_mode_of_payments_… (#55376) 2026-06-03 06:57:08 +00:00
Arshad Qureshi
86726bbd85 fix(buying): honour over delivery/receipt allowance in PR mapper (#55247) 2026-06-03 06:52:48 +00:00
mergify[bot]
8164782263 feat: add New Zealand chart of accounts (backport #55478) (#55571)
Co-authored-by: Imesha Sudasingha <imesha.sudasingha@gmail.com>
2026-06-03 12:11:28 +05:30
Shllokkk
0c61ad4e6d Avoid status updation for purchase invoice from paid to unpaid by issuing a paid debit note against it (#54382) 2026-06-03 12:04:19 +05:30
Shubh Doshi
5074597d00 perf: batch status check for on-hold/closed documents, remove N+1 queries (#54798) 2026-06-03 11:50:49 +05:30
Loic Oberle
42383c3f36 refactor(sales_invoice): replace sql with qb in delete_loyalty_point_… (#55379) 2026-06-03 11:29:04 +05:30
Mihir Kandoi
3b2f2168d0 Merge pull request #55375 from loicdokos/refactor/sales_invoice-get_mode_of_payment_info
refactor(sales_invoice): replace sql with qb in get_mode_of_payment_info
2026-06-03 11:27:31 +05:30
Luis Mendoza
36dc196a1d fix: prevent double rounding in inclusive tax calculations (#52512)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-06-03 11:09:42 +05:30
Nabin Hait
04443ae29e Merge upstream/develop into erpnext-refactoring
Resolve 4 conflicts from Phase 7 service/mapper extraction vs upstream:
- asset.py: take extraction; repoint dangling make_asset_movement JS to mapper
- job_card: port upstream field_no_map(naming_series) into mapper.make_subcontracting_po
- sales_order: port upstream rows-index fix into mapper.make_delivery_note
- sales_invoice (Phase 7): take service delegations; port upstream SQL->QB/ORM
  changes for get_warehouse, get_all_mode_of_payments, get_discounting_status,
  clear_unallocated_mode_of_payments, and set_pos_fields(POS DN skip) into services
2026-06-03 11:05:01 +05:30
Vishnu Priya Baskaran
da82ac86b5 fix payment schedule discount date when no discount is applied (#55462) 2026-06-03 10:56:19 +05:30
Shllokkk
efb8336bf8 fix: remove ignore_permissions from get_party_details signature (#55491) 2026-06-03 10:51:44 +05:30
Khushi Rawat
b1882dc83a Merge pull request #55562 from khushi8112/budget-variance-cost-center-hierarchy
fix: aggregate child cost center data in Budget Variance Report
2026-06-03 03:17:57 +05:30
khushi8112
41884cfd2a refactor: replace db.sql with frappe.qb 2026-06-03 02:48:56 +05:30
Khushi Rawat
48700a8aa3 Merge pull request #54840 from Hemil-Sangani/fix/budget-variance-report-filter
fix: add company filter to Budget Against dimension options
2026-06-03 02:30:52 +05:30
khushi8112
c34eeee096 fix: move Company filter at the start 2026-06-03 02:14:14 +05:30
Raffael Meyer
016b64df6d fix(item): format integer numeric variant attributes without decimals (#55561) 2026-06-02 22:42:32 +02:00
khushi8112
cd7fa56ec4 fix: aggregate child cost center data in Budget Variance Report 2026-06-03 02:02:03 +05:30
Raffael Meyer
e94bd51764 perf(transaction): exit early before backend query (#55556) 2026-06-02 20:24:10 +02:00
rohitwaghchaure
e1ea14b135 Merge pull request #54785 from aerele/fix/support-#67579
fix(stock): add validation for work order serial nos and batch nos
2026-06-02 18:06:42 +05:30
ruthra kumar
7afe5d4ee3 Merge pull request #54974 from Soham-ambibuzz/philipinnes_localization_coa_v2
feat: added cost of goods sold
2026-06-02 17:23:48 +05:30
Khushi Rawat
d154796c82 Merge pull request #55484 from khushi8112/accounting-dashboard-number-cards-fiscal-year
fix: use fiscal year instead of calendar year in accounting dashboard number cards
2026-06-02 16:54:32 +05:30
ruthra kumar
d6f9e4ac3f Merge pull request #54979 from rtdany10/ppr-adv-error
fix(ppr): make default_advance_account optional
2026-06-02 15:36:04 +05:30
Khushi Rawat
10c18ca801 Merge pull request #55539 from khushi8112/warn-before-cancel-reconciled-payment-entry
feat(payment-entry): warn user before cancelling reconciled payment entry
2026-06-02 15:29:15 +05:30
Mihir Kandoi
0a49403838 fix: unable to submit subcontracted job card (#55537) 2026-06-02 09:18:42 +00:00
khushi8112
f0ba54d957 feat(payment-entry): warn user before cancelling reconciled payment entry 2026-06-02 14:47:39 +05:30
Loïc Oberle
7ee7c4253b fix(sales_invoice): switch parent and child doctype
Switch the parent and child doctype in sales_invoice.py
2026-06-02 10:42:19 +02:00
shahzeelahmed
519dc0b958 fix: include CRM Deal in quotation to filters 2026-06-02 12:39:29 +05:30
Mihir Kandoi
85be72a403 fix: minor improvements to web templates, banking page and CI workflow (#55525)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:26:23 +05:30
Mihir Kandoi
78f9434d14 refactor: resolve regression-safe CodeQL code-quality findings (#55531)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:25:32 +05:30
Nabin Hait
530e587bf2 refactor: use mapper paths directly, drop re-export shims
Repoint all JS method strings and Python imports for mapper functions
across 18 doctypes from the doctype module to its mapper module, and
remove the now-unused re-export shims from each doctype file (keeping
only names used internally).
2026-06-01 18:17:56 +05:30
khushi8112
c68918bc18 fix: set a fallback value if no fiscal year set 2026-06-01 13:13:29 +05:30
khushi8112
e8fff2fdad fix: use fiscal year instead of calendar year in accounting dashboard number cards 2026-06-01 12:47:31 +05:30
Shllokkk
e460e83516 fix: use new_doc with field allowlist in CRM integration endpoints 2026-05-31 18:42:26 +05:30
Nabin Hait
498cd2b371 refactor(sales_invoice): extract non-GL services (Phase 7)
Split the sales_invoice.py monolith into focused service modules under
sales_invoice/services/:

- fixed_assets.py      — FixedAssetService (depreciation, disposal, split)
- inter_company.py     — validate/link/unlink inter-company docs
- loyalty.py           — LoyaltyService (earn, redeem, delete points)
- pos.py               — POSService + POS free functions
- status.py            — StatusService + is_overdue / get_discounting_status
- timesheet_billing.py — TimesheetBillingService

Lifecycle hooks (validate/on_submit/on_cancel) call services directly;
no thin shims. The 7 methods POS Invoice calls via self.* are kept on
the class with an explicit comment. @frappe.whitelist() doc-methods and
framework hooks (set_status, set_indicator) stay on the class.

sales_invoice.py: 2156 → 1205 lines. All 29 snapshot + 121 SI tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:52:26 +05:30
yash14023
9084570d18 fix: add docstrings and unify update_stock visibility in JS 2026-05-31 11:23:39 +05:30
Nabin Hait
c324c823fb fix(purchase_order): re-export get_mapped_subcontracting_order for test compatibility 2026-05-29 16:56:40 +05:30
Nabin Hait
516406c25b fix(purchase_order): re-export get_mapped_purchase_invoice for test compatibility 2026-05-29 13:39:35 +05:30
Nabin Hait
61da2302ba refactor(asset): move mapping functions to mapper.py 2026-05-29 13:38:00 +05:30
Nabin Hait
35ac7155e8 refactor(subcontracting_receipt): move mapping functions to mapper.py 2026-05-29 13:28:37 +05:30
Nabin Hait
28c3d24b86 refactor(lead): move mapping functions to mapper.py 2026-05-29 13:26:31 +05:30
Nabin Hait
9b85773757 refactor(opportunity): move mapping functions to mapper.py 2026-05-29 13:23:56 +05:30
Nabin Hait
341fad04c9 refactor(job_card): move mapping functions to mapper.py 2026-05-29 13:20:50 +05:30
Nabin Hait
0a4fa5e35e refactor(work_order): move mapping functions to mapper.py 2026-05-29 13:11:38 +05:30
Nabin Hait
f9d67ebb1e refactor(purchase_invoice): move mapping functions to mapper.py 2026-05-29 13:04:08 +05:30
Nabin Hait
7b456c6405 refactor(sales_invoice): move mapping functions to mapper.py 2026-05-29 13:01:38 +05:30
Nabin Hait
92983255b3 refactor(pick_list): move mapping functions to mapper.py 2026-05-29 12:45:32 +05:30
Nabin Hait
7b9f61e058 refactor(material_request): move mapping functions to mapper.py 2026-05-29 12:41:44 +05:30
Nabin Hait
0968adafc8 refactor(purchase_receipt): move mapping functions to mapper.py 2026-05-29 12:36:08 +05:30
Nabin Hait
220b6fe572 refactor(delivery_note): re-export make_inter_company_transaction 2026-05-29 12:33:42 +05:30
Nabin Hait
8192d70f83 refactor(delivery_note): move mapping functions to mapper.py 2026-05-29 12:32:57 +05:30
Nabin Hait
2cf51a0367 refactor(request_for_quotation): move mapping functions to mapper.py 2026-05-29 12:26:55 +05:30
Nabin Hait
01e7224210 refactor(supplier_quotation): move mapping functions to mapper.py 2026-05-29 12:23:31 +05:30
Nabin Hait
18d1a88a64 refactor(purchase_order): move mapping functions to mapper.py 2026-05-29 12:22:17 +05:30
Nabin Hait
cfd37f22db refactor(customer): move mapping functions to mapper.py 2026-05-29 12:19:09 +05:30
Nabin Hait
cfff10463c refactor(quotation): move mapping functions to mapper.py 2026-05-29 12:16:53 +05:30
Nabin Hait
25e3d6042a refactor(sales_order): move mapping functions to mapper.py
Separates all make_*/create_* document-creation functions from the
SalesOrder controller into a dedicated mapper.py for better separation
of concerns. Re-exports from sales_order.py preserve backward compat.
2026-05-29 12:14:27 +05:30
Nabin Hait
0a02727638 fix: move ignore_linked_doctypes assignment to on_cancel in AssetRepair
Semgrep rule frappe-modifying-but-not-comitting-other-method flags
setting self.ignore_linked_doctypes inside make_gl_entries() instead
of in the calling on_cancel method. Follows the same pattern used by
AssetCapitalization.
2026-05-29 04:03:30 +05:30
Nabin Hait
a12d666037 refactor: extract child item update cluster into ChildItemUpdater service
accounts/services/child_item_update.py:
- ChildItemUpdater class with update() entry point encapsulating all
  the logic from the old update_child_qty_rate free function; nested
  closures (check_doc_permissions, validate_workflow_conditions,
  validate_quantity_and_rate, validate_fg_item_for_subcontracting)
  become private methods on the class
- update_child_qty_rate kept as @frappe.whitelist() thin wrapper;
  re-exported from accounts_controller.py so the JS whitelist path
  "erpnext.controllers.accounts_controller.update_child_qty_rate"
  and test imports continue to work
- Free functions: set_order_defaults, validate_child_on_delete,
  update_bin_on_delete, validate_and_delete_children, get_allow_zero_qty,
  get_child_item_change_state, is_child_item_unchanged,
  update_child_item_rate_and_discount, update_child_item_uom_and_weight,
  check_if_child_table_updated

accounts_controller.py drops from ~2356 to ~1796 lines.
2026-05-28 20:38:25 +05:30
Nabin Hait
c7b4806117 refactor: extract party validation and inter-company logic into service classes
- accounts/services/party_validation.py: PartyValidator class with
  single validate() entry point covering party frozen/disabled check,
  party accounts, currency, party account currency, address/contact,
  and company-linked addresses. AccountsController.get_party() kept
  as a shim (called by advances and payment_schedule services).

- accounts/services/internal_transfer.py: InternalTransferService
  class with validate() (reference + transaction rate + pricing/tax
  disablers), set_account() for unrealized P&L, is_internal_transfer(),
  process_common_party_accounting(), and get_common_party_link().
  Shims retained on AccountsController for the three methods called
  by selling/buying/stock controllers and GL composers.

accounts_controller.py drops from ~2722 to ~2356 lines.
2026-05-28 20:10:45 +05:30
Nabin Hait
6c1ac51d7a refactor: convert payment schedule and billing validation to service objects
Introduce PaymentScheduleService and BillingValidationService classes so
call sites read PaymentScheduleService(doc).set_payment_schedule() instead
of the opaque self.set_payment_schedule() shim. Removes 15 shim methods
from AccountsController and updates all 11 call sites across the codebase.
2026-05-28 17:13:23 +05:30
Nabin Hait
8aaa3a72ef refactor: convert tax cluster to TaxService class in taxes.py
Replaces the shim+free-function pattern with a TaxService class so
callers like TaxService(self).set_taxes() make the source location
explicit. Class lives in taxes.py above the existing free functions.
Deletes the intermediate tax_service.py. Updates AccountsController,
sales_invoice, pos_invoice, subscription, and both GL composers to
call TaxService directly.
2026-05-28 16:53:03 +05:30
Loïc Oberle
2c0f6c50df refactor(sales_invoice): replace sql with qb in get_mode_of_payment_info
Replace sql with query builder to ensure compatibility with postgres

Contribution made on behalf of Orange SA
2026-05-28 10:52:46 +02:00
Nabin Hait
0ee0d6f0c5 refactor: extract tax cluster from AccountsController into services/taxes.py
Moves set_taxes, is_pos_profile_changed, set_taxes_and_charges,
append_taxes_from_master, append_taxes_from_item_tax_template,
get_tax_row, set_other_charges, validate_enabled_taxes_and_charges,
validate_tax_account_company, get_tax_map, get_amount_and_base_amount,
get_tax_amounts, and make_discount_gl_entries into free functions in
accounts/services/taxes.py. AccountsController retains thin shims.
Removes now-unused parse_json import.
2026-05-28 12:32:04 +05:30
Nabin Hait
bb803a8f82 refactor: extract billing, payment schedule, and exchange gain/loss into services
Move billing validation, payment schedule, and exchange gain/loss logic from
AccountsController into dedicated service modules under accounts/services/.
AccountsController retains thin shim methods that delegate to the services.
2026-05-27 23:42:50 +05:30
Nabin Hait
983d80f7c5 refactor(accounts): merge gl_entry_builder.py into base_gl_composer.py
The free functions (get_gl_dict, add_gl_entry, get_voucher_subtype, etc.) live
in the same module as BaseGLComposer — they are all about building GL entries,
so there is no reason to split them across two files. Removes gl_entry_builder.py
and updates all import references to base_gl_composer.
2026-05-27 22:32:06 +05:30
Nabin Hait
cba6a31497 refactor(accounts): extract get_gl_dict and add_gl_entry into gl_entry_builder.py
Move the get_gl_dict/add_gl_entry logic from AccountsController/StockController
into free functions in accounts/services/gl_entry_builder.py with doc as first arg.
BaseGLComposer gains get_gl_dict and add_gl_entry methods that delegate to the free
functions — GL composers now call self.get_gl_dict/self.add_gl_entry directly
without going through the doc. AccountsController and StockController keep thin
shims for backward compatibility with unrefactored callers.

Also move update_gl_dict_with_regional_fields and update_gl_dict_with_app_based_fields
to gl_entry_builder.py, re-exporting them from accounts_controller.py to avoid a
circular import.
2026-05-27 21:46:16 +05:30
Sudharsanan11
9ad046109c test(stock): add test to validate the reserved serial/batch nos for fg items 2026-05-27 17:32:19 +05:30
Nabin Hait
29261c5fc2 refactor(accounts): extract tax helpers into accounts/services/taxes.py
Move validate_conversion_rate, validate_taxes_and_charges, validate_account_head,
validate_cost_center, validate_inclusive_tax, set_balance_in_account_currency,
set_child_tax_template_and_map, add_taxes_from_tax_template, merge_taxes,
get_tax_rate, get_default_taxes_and_charges, and get_taxes_and_charges out of
accounts_controller into a dedicated accounts/services/taxes.py module.

Re-export all symbols from accounts_controller for backward compatibility.
2026-05-27 16:28:02 +05:30
Nabin Hait
58c90ad651 refactor(accounts): extract advance payment logic into accounts/services/advances.py
Moves all advance-related query and management logic out of the 4500-line
AccountsController into a dedicated module-level service:
- get_advance_journal_entries, get_advance_payment_entries,
  get_advance_payment_entries_for_regional, get_common_query
- set_advances, get_advance_entries, validate_advance_entries,
  set_advance_gain_or_loss, calculate_total_advance_from_ledger,
  set_total_advance_paid, set_advance_payment_status,
  delink_advance_entries, create_advance_and_reconcile

AccountsController methods become thin shims; module-level functions in
accounts_controller.py are replaced with re-exports for backward
compatibility. payment_reconciliation.py updated to import directly from
the new service.

All 29 GL snapshots, 121 SI tests, 53 PE tests, and 37 payment
reconciliation tests pass.
2026-05-27 15:55:28 +05:30
Nabin Hait
8783689ec5 refactor(accounts): GL composer pattern for SCR, AssetCapitalization, AssetRepair
Extracts get_gl_entries logic from SubcontractingReceipt,
AssetCapitalization, and AssetRepair into dedicated GL composer classes
under each doctype's services/ package. Each composer follows the
established BaseGLComposer / BaseStockGLComposer pattern, and the
original get_gl_entries becomes a 3-line shim.

- SubcontractingReceiptGLComposer(BaseStockGLComposer): moves
  make_item_gl_entries and make_item_gl_entries_for_lcv
- AssetCapitalizationGLComposer(BaseStockGLComposer): moves
  get_gl_entries_for_consumed_{stock,asset,service}_items and
  get_gl_entries_for_target_item; inventory_account_map/sle_map/precision
  become composer instance attributes
- AssetRepairGLComposer(BaseGLComposer): moves
  get_gl_entries_for_repair_cost and get_gl_entries_for_consumed_items
  (AR inherits AccountsController, not StockController)

All 29 GL snapshot tests and existing doctype test suites (32 SCR,
5 AC, 18 AR) pass.
2026-05-27 15:34:54 +05:30
Nabin Hait
8d3efe287e refactor: introduce PurchaseReceiptGLComposer
purchase_receipt/services/gl_composer.py → PurchaseReceiptGLComposer(BaseStockGLComposer).
compose() orchestrates the four builder steps: _make_item_gl_entries,
_make_tax_gl_entries, set_gl_entry_for_purchase_expense (stays on doc),
update_regional_gl_entries (module-level).

_make_item_gl_entries preserves the original closure structure (six inner
functions: make_item_asset_inward_gl_entry, make_stock_received_but_not_billed_entry,
make_landed_cost_gl_entries, make_amount_difference_entry,
make_sub_contracting_gl_entries, make_divisional_loss_gl_entry); all doc
calls go through self.doc.  _make_tax_gl_entries is a direct port.

Helpers that stay on the document: add_provisional_gl_entry (public —
PI composer calls it via purchase_receipt_doc.add_provisional_gl_entry),
add_gl_entry, get_item_account_wise_lcv_entries, update_assets,
is_landed_cost_booked_for_any_item.

PurchaseReceipt.get_gl_entries is now a 3-line shim; make_item_gl_entries
and make_tax_gl_entries removed from the class.

Verified: 29 GL snapshots byte-identical on test-erpnext-v17;
101 PR tests green on test-site-ai.
2026-05-27 15:17:34 +05:30
Nabin Hait
b63e1fd796 test: add Purchase Receipt GL snapshots
Extends the Phase-0 characterization suite with 3 PR scenarios:
  pr_basic, pr_with_taxes, pr_return — all using _Test Company with
  perpetual inventory (TCP1) so stock-received GL entries are produced.

Also refreshes se_material_issue.json (cumulative stock on test-erpnext-v17
shifted the outgoing valuation rate). 29 snapshots total, all green.
2026-05-27 15:17:00 +05:30
Nabin Hait
18188cb1b2 refactor: introduce StockEntryGLComposer and StockReconciliationGLComposer
Stock Entry
  stock_entry/services/gl_composer.py → StockEntryGLComposer(BaseStockGLComposer)
  compose() calls super().compose() for the base warehouse↔expense GL pairs,
  then adds additional-cost entries (_build_additional_cost_per_item_account +
  _append_additional_cost_gl_entries) and LCV adjustments (_append_lcv_gl_entries).
  get_item_account_wise_lcv_entries stays on StockController (called via self.doc).
  StockEntry.get_gl_entries is now a 3-line shim.
  Removed private helpers from StockEntry; dropped unused process_gl_map and
  get_account_currency imports.

Stock Reconciliation
  stock_reconciliation/services/gl_composer.py → StockReconciliationGLComposer(BaseStockGLComposer)
  compose() guards cost_center and delegates to
  super().compose(inventory_account_map, doc.expense_account, doc.cost_center).
  StockReconciliation.get_gl_entries is now a 3-line shim.

Verified: 26 GL snapshots byte-identical on test-erpnext-v17;
89 SE tests and 33/34 SR tests green on test-site-ai
(1 pre-existing SR failure in test_serial_no_status_with_backdated_stock_reco,
unrelated to GL — IndexError in serial bundle setup).
2026-05-27 15:01:27 +05:30
Nabin Hait
001c70831c test: add Stock Entry and Stock Reconciliation GL snapshots
Extends the Phase-0 characterization suite with 4 scenarios:
  se_material_receipt, se_material_issue, se_material_transfer, sr_basic.

All use _Test Company with perpetual inventory (TCP1) so stock accounting
GL entries are produced. 26 snapshots total, all green on test-erpnext-v17.
2026-05-27 15:01:10 +05:30
Nabin Hait
b68daea365 refactor: introduce BaseStockGLComposer, slim StockController.get_gl_entries
Moves the StockController.get_gl_entries body into
erpnext/stock/services/base_stock_gl_composer.py → BaseStockGLComposer(BaseGLComposer).
compose(inventory_account_map, default_expense_account, default_cost_center) contains
all warehouse↔expense-account GL pair building and the internal-transfer rounding-diff
block; all helpers (get_inventory_account_dict, get_stock_ledger_details, etc.) remain
on self.doc and are called via doc.<method>.

StockController.get_gl_entries becomes a 3-line shim.  Delivery Note, Stock Entry, and
Stock Reconciliation continue to work unchanged — DN inherits the shim directly; SE and
SR override and call super(), which now delegates to the composer.

Verified: 22 GL snapshots byte-identical on test-erpnext-v17.
2026-05-27 14:47:19 +05:30
Nabin Hait
e8f9cf6e3f test: add Delivery Note GL snapshots
Extends the Phase-0 characterization suite with 2 DN scenarios (basic
delivery and return) using _Test Company with perpetual inventory so
stock accounting GL entries are produced. Uses stock_entry_utils.make_stock_entry
directly (avoids importing test_delivery_note and its conflicting test-record deps).

Run: bench --site test-erpnext-v17 run-tests --module erpnext.accounts.test_gl_characterization
2026-05-27 14:46:40 +05:30
Nabin Hait
55368256fd docs: mark Phase 4 Journal Entry as done in refactor spec 2026-05-27 12:49:11 +05:30
Nabin Hait
8f05e0596e refactor: introduce Journal Entry GL composer
Move the Journal Entry GL assembly into a new JournalEntryGLComposer(
BaseGLComposer); compose() projects the accounts child rows into GL dicts,
mirroring the former build_gl_map, which is now a thin shim delegating to
the composer. Drop the now-unused get_advance_payment_doctypes import.
2026-05-27 12:49:05 +05:30
Nabin Hait
473f6e833a test: add Journal Entry GL characterization snapshots
Extend the Phase-0 GL safety net with three representative Journal Entry
scenarios (basic two-line, multi-currency, against a Sales Invoice with
party and reference) ahead of moving JE onto the composer.
2026-05-27 12:48:57 +05:30
Nabin Hait
d775d540c4 docs: mark Phase 4 Payment Entry as done in refactor spec 2026-05-27 12:42:59 +05:30
Nabin Hait
b381061742 refactor: introduce Payment Entry GL composer
Move the Payment Entry GL row builders (party, bank, deductions, tax)
onto a new PaymentEntryGLComposer(BaseGLComposer); compose() mirrors the
former build_gl_map, which is now a thin shim delegating to the composer.
The builders operate on self.doc and shared helpers stay on the document.
Advance-posting builders are left on the controller; they post in a
separate pass and move with the advances service in a later phase.
2026-05-27 12:42:52 +05:30
Nabin Hait
90801550eb test: add Payment Entry GL characterization snapshots
Extend the Phase-0 GL safety net with five representative Payment Entry
scenarios (receive against SI, pay against PI, with deductions, with
taxes, multi-currency) ahead of moving PE onto the composer.
2026-05-27 12:42:43 +05:30
Nabin Hait
8677e2df40 docs: mark Phase 3 as DONE in refactor spec 2026-05-27 08:40:26 +05:30
Nabin Hait
9c78c9ab7b refactor: migrate PI item/stock/provisional GL builders onto the composer
Move make_item_gl_entries, make_stock_adjustment_entry,
get_provisional_accounts, make_provisional_gl_entry, and
update_net_purchase_amount_for_linked_assets from PurchaseInvoice onto
PurchaseInvoiceGLComposer, completing the full GL builder migration.

purchase_invoice.py no longer contains any GL row-building logic;
PurchaseInvoiceGLComposer is the single authoritative source for all
PI GL entries, mirroring the SalesInvoiceGLComposer pattern.

All 12 GL characterization snapshots pass.
2026-05-27 08:40:01 +05:30
Nabin Hait
32c4b1d98a refactor: migrate PI supplier/tax/payment GL builders onto the composer
Move make_supplier_gl_entry, add_supplier_gl_entry, make_tax_gl_entries,
make_internal_transfer_gl_entries, make_gl_entries_for_tax_withholding,
make_payment_gl_entries, make_write_off_gl_entry, and
make_gle_for_rounding_adjustment from PurchaseInvoice onto
PurchaseInvoiceGLComposer.

compose() now calls self.X for all moved builders; the make_item cluster
(make_item_gl_entries, make_provisional_gl_entry, get_provisional_accounts,
update_net_purchase_amount_for_linked_assets, make_stock_adjustment_entry)
still lives on doc pending batch-2 migration.

All 12 GL characterization snapshots pass.
2026-05-27 08:29:32 +05:30
Nabin Hait
6467f07459 refactor: introduce Purchase Invoice GL composer
Phase 3 of the accounts/controller refactor. Adds PurchaseInvoiceGLComposer;
PI's get_gl_entries body moves into compose() and the method becomes a thin
shim. Row-builder methods still live on the document (invoked via self.doc)
and migrate onto the composer next.

After comparing the SI and PI compose() flows, BaseGLComposer is kept minimal:
the two differ in step order, builders, and per-doctype regional function, so a
shared template is not warranted. No behavior change (Phase 0 snapshots and PI
GL tests stay green).
2026-05-27 08:13:10 +05:30
Nabin Hait
b5c96dfef0 refactor: move Sales Invoice GL row builders onto the composer
Relocates all 11 Sales Invoice-specific GL entry builders from the document
onto SalesInvoiceGLComposer, operating on self.doc. The perpetual-inventory
super().get_gl_entries() call becomes super(SalesInvoice, doc).get_gl_entries().
Shared bucket-A helpers (get_gl_dict, make_discount_gl_entries, etc.) remain on
AccountsController for now, invoked via self.doc, until all doctypes use a
composer. No behavior change: Phase 0 snapshots and the SI tests covering
perpetual inventory, POS, write-off, returns, fixed assets, internal transfer
and loyalty all stay green.
2026-05-27 01:24:56 +05:30
Nabin Hait
cf1817c1ea refactor: introduce GL composer and delegate SI get_gl_entries
Phase 2 (pilot) of the accounts/controller refactor. Adds BaseGLComposer
and SalesInvoiceGLComposer; Sales Invoice's get_gl_entries body moves into
compose() and the method becomes a thin shim. Row-builder methods still live
on the document (invoked via self.doc) and migrate onto the composer next.
No behavior change (Phase 0 GL snapshots remain byte-identical).
2026-05-27 01:12:11 +05:30
Nabin Hait
3ec6387425 fix: honor account freezing date when cancelling vouchers
make_reverse_gl_entries passed adv_adj as the company argument to
check_freezing_date, so the freeze-date check silently no-op'd on
cancellation (no company matched). Pass company explicitly so
cancellations respect the freezing date like submissions do.

Adds a regression test covering cancellation after the freeze date.
2026-05-27 01:01:43 +05:30
Nabin Hait
234c4a45b8 refactor: extract list-level GL validations into gl_validator service
Phase 1 of the accounts/controller refactor. Moves the six pure
list-level validators (validate_disabled_accounts, validate_accounting_period,
validate_cwip_accounts, check_freezing_date, validate_against_pcv,
validate_allowed_dimensions) out of general_ledger.py into the new
erpnext/accounts/services/gl_validator.py. general_ledger.py imports and
calls them at the existing sites; no behavior change (Phase 0 GL snapshots
remain byte-identical).

The debit/credit balance trio stays in general_ledger.py for now since
get_debit_credit_difference mutates entries and is interleaved with the
round-off repair.
2026-05-27 01:01:20 +05:30
Nabin Hait
064340cafb test: add Phase 0 GL characterization safety net
Golden-master snapshot harness (GLSnapshot / assert_gl_snapshot) plus 12
characterization scenarios for Sales and Purchase Invoice (basic, taxes,
multi-currency, returns, round-off, discount accounting, advance, POS).
Locks current GL Entry output so the upcoming GL pipeline refactor
(composer / validator / sink) can be verified byte-identical.

Regenerate goldens with REGEN_GL_SNAPSHOTS=1.
2026-05-27 00:49:48 +05:30
Nabin Hait
dfbd8db9d3 docs: add accounts/controller refactor spec
Phased plan to decompose accounts_controller.py and the sales_invoice.py
monolith into composed services. Documents the frozen GL-layer design
(GLComposer / gl_validator / general_ledger sink), method bucketing, and
the 8-phase rollout.
2026-05-27 00:05:35 +05:30
Sudharsanan11
58f24c83c0 fix(stock): add validation for work order seial nos and batch nos 2026-05-26 18:27:59 +05:30
nareshkannasln
b1de654dfd fix: update reference doctype mapping and field visibility in bank guarantee 2026-05-26 12:39:51 +05:30
yash14023
d57786caa2 fix(accounts): unify update_stock visibility logic in JS 2026-05-26 10:24:26 +05:30
yash14023
a2f877cee6 fix(accounts): prevent update_stock on Debit Notes
Extracted validation into validate_debit_note_with_update_stock().
Hide update_stock in JS via set_dynamic_labels() and is_debit_note handler.
Added unit test asserting ValidationError on save.

Fixes #54891
2026-05-26 01:54:06 +05:30
Abdeali Chharchhoda
814c11200a fix: update formatter to handle blank rows in financial statements 2026-05-20 17:31:21 +05:30
Abdeali Chharchhoda
f7c744350c fix: update add_total_row_account to control blank row addition 2026-05-20 17:15:44 +05:30
Abdeali Chharchhoda
cf597361f6 fix: handle separator rows in financial statement formatter 2026-05-20 16:28:38 +05:30
soham7117
88f6f182e3 feat: removed extra page break
Signed-off-by: soham7117 <sohampawar626@gmail.com>
2026-05-20 14:57:29 +05:30
soham7117
4c8f95a1a5 feat: added cost of goods sold
Signed-off-by: soham7117 <sohampawar626@gmail.com>
2026-05-20 14:57:29 +05:30
Shllokkk
9ea56910a1 test: update setup for test_process_statement_of_accounts 2026-05-17 19:51:52 +05:30
Shllokkk
d2b09f71c3 fix: populate missing letter_head_for in tabLetter Head and set default letterheads 2026-05-16 17:59:30 +05:30
Shllokkk
f31b3749bc feat: standard letterheads for doctype and reports 2026-05-16 17:54:02 +05:30
Dany Robert
30b9e11303 fix: update default_advance_account type 2026-05-16 12:09:59 +05:30
Dany Robert
4b1d369ac6 fix(ppr): make default_advance_account optional 2026-05-16 11:48:18 +05:30
Ahmed Reda Abukhatwa
3592c3086d fix: skip empty spacer rows in compute_growth_view_data (P&L growth view) 2026-05-14 14:40:03 +03:00
HemilSangani
bdf0136fc5 fix: add company filter to Budget Against dimension options 2026-05-11 18:58:57 +05:30
Ahmed Reda Abukhatwa
7335011814 fix(profit-loss-report): handle zero base values and prevent null% display 2026-04-30 20:54:44 +03:00
Ahmed Reda Abukhatwa
671555edbc fix(profit-and-loss-statement): margin calculation the report showing null% for empty cell 2026-04-30 20:54:28 +03:00
Ahmed Reda Abukhatwa
df6fd782b7 fix(profit-and-loss-statement-report): margin calculation the report showing null% for empty cell 2026-04-30 20:54:07 +03:00
905 changed files with 384271 additions and 147917 deletions

183
.github/POSTGRES_COMPATIBILITY.md vendored Normal file
View File

@@ -0,0 +1,183 @@
# PostgreSQL compatibility — review guide
ERPNext targets **both MariaDB and PostgreSQL from a single codebase**. The full server
test suite passes on both, but the PostgreSQL CI job is **label-gated** (it does not run on
every PR), so until it is required this guide is the always-on guard. Greptile loads it as
review context (`.greptile/config.json`).
When reviewing a PR, flag any **new or changed query** (raw `frappe.db.sql`, `frappe.qb`,
`frappe.get_all/get_list/get_value`, report SQL) that would **error on PostgreSQL** or
**return different results on the two engines**.
## The one rule that governs everything
**MariaDB behaviour must not change; PostgreSQL is brought into line with MariaDB — never the
reverse.** A "fix" that changes the value, row count, or ordering MariaDB produced is a
regression, even if the new behaviour looks more correct. The only accepted MariaDB-output
change is replacing a genuinely *undefined/arbitrary* result with a deterministic one (row
count preserved) — and that should be called out explicitly.
There are two failure modes to watch for:
1. **Hard breaks** — PostgreSQL raises an exception; MariaDB is green. Easy to catch in CI,
but the gated job may not run.
2. **Silent divergences** — both engines succeed but return *different* results. CI on one
engine stays green; the bug only shows on a PostgreSQL site. These are the dangerous ones.
---
## 1. Hard breaks — would error on PostgreSQL
Flag a changed query that uses any of these:
- **Loose `GROUP BY`** — selecting/ordering a column that is neither in `GROUP BY` nor wrapped
in an aggregate. MariaDB tolerates it; PostgreSQL errors (`must appear in the GROUP BY
clause or be used in an aggregate function`). This **also covers an aggregate (`Sum`/`Count`/…)
selected alongside bare columns with NO `.groupby()` at all** — MariaDB silently collapses
every row into one arbitrary-valued row (often a *wrong-output* bug there too), PostgreSQL
errors. Fix: add the bare column to `GROUP BY` **if it is functionally dependent on the group
key**, otherwise wrap it in `Max()`/`Min()`. **See §3 — the row-count trap — before suggesting
"add it to GROUP BY".**
- **MySQL-only functions** — `TIMESTAMP(date,time)`, `TIMEDIFF`, `STR_TO_DATE`, `DATE_FORMAT`,
`DATE_ADD/SUB`, `GROUP_CONCAT`, `PERIOD_DIFF`, SQL `IF(cond,a,b)`. Use the portable
`frappe.query_builder.functions` equivalents (`CombineDatetime`, `DateDiff`, `Case`,
`GroupConcat`, …) or a precomputed column (e.g. `posting_datetime`).
- **`UPDATE … JOIN`** — not valid on PostgreSQL. Rewrite as `UPDATE … WHERE name IN (subquery)`.
- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in
`HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the
underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`.
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select.
- **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an
unquoted (or double-quoted) alias.
- **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use
`Coalesce(...)`.
- **Capital-cased identifiers** used as column/field names in `get_value(dt, dn, "Status")`,
`get_all(dt, fields=["Account"])`, and similar — PostgreSQL quotes the identifier and matches
it case-sensitively; a stored column named `status`/`account` won't match `"Status"`/`"Account"`
(`column "Account" does not exist`). Use the exact stored (lower-case) fieldname.
- **Boolean passed where an integer column is expected** — `frappe.db.set_value(dt, dn,
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
Use `VARCHAR` / `Cast_(x, "varchar")`.
- **`.rlike()` / raw `RLIKE`** — frappe rewrites `REGEXP` → `~*` on PostgreSQL but does **not**
translate `RLIKE` (no such PostgreSQL operator). Use `.regexp()` (or `.like()` for a simple prefix).
- **`IfNull`/`Coalesce` of a typed column with a different-typed literal** — `IfNull(asset.disposal_date, 0)`
renders `COALESCE("disposal_date", 0)`, coalescing a **DATE** with an **integer**. PostgreSQL requires
`COALESCE` args to share a type (`DatatypeMismatch: COALESCE types date and integer cannot be matched`);
MariaDB's `IFNULL` is permissive. The common shape is `IfNull(date_col, 0) != 0 / == 0` as a presence test —
replace with `date_col.isnotnull()` / `date_col.isnull()` (identical, and valid on both). Otherwise coalesce
to a **same-type** default (`Coalesce(date_col, '1900-01-01')`, `Coalesce(text_col, '')`).
- **Division by a possibly-zero divisor** — `Sum(a) / Sum(b)`, `x / col`, etc. where the
divisor can be `0`/empty. MariaDB returns `NULL` for division by zero; PostgreSQL raises
`division by zero` and aborts the query. Wrap the divisor in `NullIf(divisor, 0)` — that
yields `NULL` on both engines, matching MariaDB's value. (Only the *literal* `/ 0` is a parse
constant; the trap is a divisor that is an aggregate or column the data can drive to zero.)
---
## 2. Silent divergences — succeeds on both, returns different results
These don't error, so a one-engine CI stays green. Flag them:
- **Case sensitivity on text equality** — `==`, `.isin()`, `Strpos`/`Locate` on free-text
columns are case-**sensitive** on PostgreSQL but case-**insensitive** under MariaDB's default
collation. `Lower()` both sides. *(Not `.like()`/`["like", …]` — those already render as
`ILIKE` on PostgreSQL; see §4.)*
- **Case sensitivity in a doc-`name` lookup** — lower-casing a value then using it as a
document name in `get_value`/`get_doc`/`exists` misses on PostgreSQL (names are
case-sensitive). Keep original case for the identifier; lower-case only comparison operands.
- **Empty string vs NULL** — PostgreSQL stores a blank link/data field as `NULL` on some paths
while MariaDB keeps `''`; `Concat`/`Concat_ws` then diverge. Prefer the stored full value, or
`Coalesce(col, '')` per argument.
- **NULL ordering** — MariaDB sorts `NULL` first, PostgreSQL sorts it last. For
`ORDER BY … LIMIT 1`/`[0]` on a nullable column, guard with `Coalesce`/`isnotnull()`.
- **`ORDER BY … LIMIT 1` with no unique tiebreaker** — when rows tie on the ordered column the
two engines may pick different rows. Add a `creation`/`name` tiebreaker **only if it does not
change MariaDB's current pick** (see §4).
- **Integer division** — `int / int` truncates on PostgreSQL but is decimal on MariaDB, e.g.
`COUNT(...) / COUNT(...) * 100` → `0`, or `manufacturing_time_in_mins / 1440` flooring a
lead-time to whole days. Force float: multiply by `100.0`, or make a literal a float
(`/ 1440` → `/ 1440.0`), or cast an operand. (Only SQL-level `/` on integer **columns/literals**
— Python `/` is already float.)
- **`DISTINCT` list ordering** — `frappe.get_all(distinct=True, order_by=…)` /
`SELECT DISTINCT … ORDER BY`: frappe's `db_query` **silently drops `ORDER BY` for distinct
queries on PostgreSQL**, so the result is unordered there. Sort in Python instead — and use
`key=str.casefold`, because bare `sorted()` is case-sensitive (ASCII) while MariaDB's
collation is case-insensitive, so a plain sort reorders MariaDB's output.
- **Engine-specific function rewrites** — e.g. a PostgreSQL `regexp_replace` branch
reimplementing MariaDB's `CAST(SUBSTRING_INDEX(name,' ',-1) AS UNSIGNED)` (leading digits of
the last whitespace token). Verify the rewrite matches MariaDB on edge cases (`"X - 3a"→3`,
`"X - 1.5"→1`) by diffing both engines on literal rows.
- **`UnixTimestamp(date)` / date→epoch** is timezone-dependent (midnight in the DB session TZ),
so a strict `epoch <= now` bound is flaky on PostgreSQL.
---
## 3. The `GROUP BY` row-count trap (the single most important rule)
When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent
column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and
**changes the MariaDB row count** (a regression). The classic traps are adding the **child/row
primary key** or an **editable per-row field**. Instead **`Max()`/`Min()`-wrap** the offending
column: the row count is preserved and the value goes from arbitrary (MariaDB's old loose pick)
to deterministic.
**Judge functional dependence by the source table, not the column name:**
- A column from a **master joined on the group key** (`t3.x` where `t1.key = t3.name`) is FD →
safe to keep in `GROUP BY`.
- A descriptive field on the **transaction** table (`t1.supplier_name`, `t1.territory`,
`t1.item_name` — fetched/editable, can differ across historical rows for the same key) is
**not** FD even though it looks master-derived → `Max()`-wrap it.
Conversely, do **not** suggest changing a `Max()`/`Min()`-wrapped column to `Sum()` (or vice
versa) to make a number "more correct" — that changes the MariaDB value. The wrap reproduces
MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of
scope for a portability fix.
---
## 4. False positives — do NOT flag these
These are auto-handled by the framework and are **not** breaks:
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
is a hard break, `bigint ILIKE`; see §1.)*
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a
hard break (see §1).
- **An `ORDER BY … LIMIT 1` tie where the two engines already agree**, or where adding a
tiebreaker would *change* MariaDB's current pick — leave it; "fixing" it would either change
MariaDB or has no observable effect.
---
## 5. Transaction / runtime (not query-shape, still PostgreSQL-only)
- **Catch-and-continue inserts** — on PostgreSQL a failed `insert()` aborts the **whole
transaction**, so code that swallows a duplicate and keeps going dies on the next statement
with `InFailedSqlTransaction` (frappe dropped its blanket per-statement savepoint in
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
---
## How to review
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or
(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the
portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives.
Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY:
splits the row count") so the fix is unambiguous.
The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical*
§1 breaks; the **semantic** §2/§3 divergences are exactly what a reviewer (and this guide) must
cover, because no static check can see them.

72
.github/helper/hydrate.sh vendored Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
#
# Hydrate a test shard from the setup job's artifact.
#
# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the
# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no
# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis
# so tests can run. The whole point is that the expensive work happened ONCE in the setup job.
#
set -e
ci_user="${ERPNEXT_CI_USER:-frappe}"
db_host="${DB_HOST:-127.0.0.1}"
# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as
# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci.
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
exec su -m "$ci_user" -s /bin/bash -c \
"ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' DB='${DB:-}' bash '$0'"
fi
cd ~/frappe-bench
# Start the DB on the datadir baked into the artifact. It's already populated (the setup job
# reinstalled into this very datadir), so there is NO restore — the server comes up on the
# existing files. This is what replaces the per-shard SQL replay.
bash ~/frappe-bench/start-db.sh
# Bring up redis (lightmode unit tests need cache + queue). In the self-hosted container we use the
# full `bench start` (web/workers too, like install.sh). On the bare GitHub Postgres shard
# `bench start` (honcho) lagged — it blocks the redis procs behind web/worker procs the lightmode
# suite never uses, so the wait below burned its full timeout (~4m). There, start the two redis
# instances directly: fast and deterministic.
if [ "${DB:-mariadb}" = "postgres" ]; then
# Start redis directly as daemons — reliable and persists across steps. Do NOT route it through
# `bench start`: honcho tears the whole process group down if any one Procfile proc dies on the
# bare shard, which took redis with it (redis @ 13000 refused in Run Tests). Keeping redis
# independent is what makes it survive. The web server (for PDF tests) is NOT started here — a
# backgrounded server doesn't survive into the next step; it's started inside the Run Tests step.
for conf in redis_cache redis_queue; do
[ -f ~/frappe-bench/config/$conf.conf ] && redis-server ~/frappe-bench/config/$conf.conf --daemonize yes
done
else
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
fi
# Wait for redis, failing fast instead of silently burning minutes if it never comes up.
cfg=~/frappe-bench/sites/common_site_config.json
if [ -f "$cfg" ]; then
ports=$(python - "$cfg" <<'PY'
import json, re, sys
try:
cfg = json.load(open(sys.argv[1]))
except Exception:
sys.exit(0)
for key in ("redis_cache", "redis_queue"):
m = re.search(r":(\d+)", str(cfg.get(key, "")))
if m:
print(m.group(1))
PY
)
for port in $ports; do
up=0
for _ in $(seq 1 60); do
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then exec 3>&- 3<&-; up=1; break; fi
sleep 1
done
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; exit 1; }
done
fi
echo "Hydrated: DB up on baked datadir, redis up — ready for tests."

View File

@@ -4,75 +4,360 @@ set -e
cd ~ || exit
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
pip install frappe-bench
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
db_host=${DB_HOST:-"127.0.0.1"}
db_user_host=${DB_USER_HOST:-"localhost"}
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
bench_cache_dir=${BENCH_CACHE_DIR:-}
run_as_ci_user_if_needed() {
if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then
return
fi
local missing_packages=()
if ! command -v pkg-config >/dev/null 2>&1; then
missing_packages+=("pkg-config")
fi
if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then
missing_packages+=("libmariadb-dev")
fi
if ! command -v crontab >/dev/null 2>&1; then
missing_packages+=("cron")
fi
if [ "${#missing_packages[@]}" -gt 0 ]; then
apt-get update
apt-get install -y --no-install-recommends "${missing_packages[@]}"
fi
local ci_user="${ERPNEXT_CI_USER:-frappe}"
if ! id "$ci_user" >/dev/null 2>&1; then
useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user"
fi
rm -rf ~/frappe ~/frappe-bench
local ci_dirs=(
"$HOME"
"$GITHUB_WORKSPACE"
"$HOME/.cache"
"${PIP_CACHE_DIR:-$HOME/.cache/pip}"
"${npm_config_cache:-$HOME/.npm}"
"${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}"
"$HOME/.yarn"
"${UV_CACHE_DIR:-$HOME/.cache/uv}"
"$(dirname "$wkhtmltox_deb")"
)
if [ -n "$bench_cache_dir" ]; then
ci_dirs+=("$bench_cache_dir")
fi
# Create + own (non-recursively) the home/cache/workspace dirs before dropping to
# the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent
# cache (mounted volume or baked image layer) stays warm across runs.
mkdir -p "${ci_dirs[@]}" "$HOME/.yarn"
chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn"
export ERPNEXT_CI_NON_ROOT=1
exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'"
}
run_as_ci_user_if_needed
run_ci_step() {
local label=$1
shift
echo "::group::${label}"
date -u
local exit_code=0
timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-1800}" "$@" || exit_code=$?
date -u
echo "::endgroup::"
return "$exit_code"
}
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
git config --global --add safe.directory "$GITHUB_WORKSPACE" || true
git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true
fi
rm -rf ~/frappe ~/frappe-bench
# ---------------------------------------------------------------------------
# Phase 1 — parallelise the three slow, independent setup steps:
# a) system packages b) frappe-bench pip install c) frappe git fetch
# ---------------------------------------------------------------------------
if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then
sudo apt-get update
# apt remove/install must run sequentially but can overlap with pip and git.
sudo apt-get remove -y mysql-server mysql-client
sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev &
apt_pid=$!
pip install frappe-bench &
pip_pid=$!
else
apt_pid=
pip_pid=
fi
mkdir frappe
(
cd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
) &
clone_pid=$!
if [ -n "$apt_pid" ]; then wait $apt_pid; fi
if [ -n "$pip_pid" ]; then wait $pip_pid; fi
wait $clone_pid
pushd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
git checkout FETCH_HEAD
popd
frappe_sha=$(git -C frappe rev-parse HEAD)
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
get_bench_cache_archive() {
if [ -z "$bench_cache_dir" ]; then
return
fi
mkdir ~/frappe-bench/sites/test_site
mkdir -p "$bench_cache_dir"
# Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because
# restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a
# constantly-moving develop still hit the cache.
local cache_key
cache_key=$(
{
uname -m
python --version
node --version
bench --version
} | sha256sum | awk '{print $1}'
)
echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst"
}
restore_warm_bench() {
bench_cache_archive=$(get_bench_cache_archive)
[ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1
echo "Restoring base bench from ${bench_cache_archive}"
tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1
[ -d ~/frappe-bench/apps/frappe/.git ] || return 1
mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs
[ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt
[ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json
# Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then
# rebuild only what changed. The editable install means the venv tracks the new code with
# no reinstall. Any failure returns non-zero so the caller falls back to a full bench init.
if ! (
cd ~/frappe-bench/apps/frappe || exit 1
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
# straight from it (bench init names the remote 'upstream', not 'origin', and points
# it at this local clone — so a plain `git fetch origin` does not work).
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
git checkout --force FETCH_HEAD || exit 1
); then
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
rm -rf ~/frappe-bench
return 1
fi
# Pick up any frappe dependency changes since the base was built (cached → fast if none),
# so a develop commit that bumped requirements doesn't leave a stale venv.
if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then
echo "frappe dependency refresh failed; falling back to full init"
rm -rf ~/frappe-bench
return 1
fi
( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; }
return 0
}
save_warm_bench() {
if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then
return
fi
if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then
echo "Skipping warm bench save because ${bench_cache_dir} is not writable"
return
fi
local tmp_archive
tmp_archive="${bench_cache_archive}.${$}.tmp"
echo "Saving warm bench to ${bench_cache_archive}"
# Keep sites/common_site_config.json (the redis ports live there — dropping it makes the
# restore path fall back to a default redis port that bench start never bound, so reinstall
# fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded;
# restore_warm_bench runs `bench build` to regenerate it.
tar \
--use-compress-program="zstd -T0 -3" \
--exclude="frappe-bench/logs" \
--exclude="frappe-bench/sites/assets" \
-cf "$tmp_archive" \
-C ~ frappe-bench
mv "$tmp_archive" "$bench_cache_archive"
}
# ---------------------------------------------------------------------------
# Phase 2 — bench init and site setup
# ---------------------------------------------------------------------------
install_whktml() {
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
if [ ! -f "$wkhtmltox_deb" ]; then
wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
fi
sudo apt-get install -y "$wkhtmltox_deb"
}
if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then
install_whktml &
wkpid=$!
else
wkpid=
fi
if ! restore_warm_bench; then
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
cd ~/frappe-bench || exit
sed -i 's/watch:/# watch:/g' Procfile
sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
CI=Yes bench build --app frappe
save_warm_bench
fi
if [ -n "$wkpid" ]; then wait $wkpid; fi
mkdir -p ~/frappe-bench/sites/test_site
if [ "$DB" == "mariadb" ];then
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
if [ "$db_host" != "127.0.0.1" ]; then
sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json
fi
else
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
fi
if [ "$DB" == "mariadb" ];then
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
for _ in {1..60}; do
if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then
break
fi
sleep 1
done
mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
# Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB
# 10.6, so it can't be disabled here — would need a server startup flag.)
mariadb --host "$db_host" --port 3306 -u root -proot \
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
# Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during
# reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in
# the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if
# reinstall then errors on table creation, unset it (off by default — zero risk).
if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;"
fi
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
fi
if [ "$DB" == "postgres" ];then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
# Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which
# dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a
# service-container DB and never calls start-db.sh, so the flags must be applied here.
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
-c "ALTER SYSTEM SET fsync = 'off'" \
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
-c "SELECT pg_reload_conf()";
fi
install_whktml() {
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
sudo apt install /tmp/wkhtmltox.deb
}
install_whktml &
wkpid=$!
cd ~/frappe-bench || exit
sed -i 's/watch:/# watch:/g' Procfile
sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
run_ci_step "Get payments app" bench get-app payments --branch develop
bench get-app payments --branch develop
bench get-app erpnext "${GITHUB_WORKSPACE}"
# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF
# tests (print formats) do — they pass only if the PDF renderer ignores missing assets.
# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it.
erpnext_get_app_args=()
if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi
run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}"
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi
wait $wkpid
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes
# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the
# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention
# then slows the job rather than failing it.
wait_for_redis() {
local cfg=~/frappe-bench/sites/common_site_config.json
[ -f "$cfg" ] || return 0
local ports port
ports=$(python - "$cfg" <<'PY'
import json, re, sys
try:
cfg = json.load(open(sys.argv[1]))
except Exception:
sys.exit(0)
for key in ("redis_cache", "redis_queue"):
match = re.search(r":(\d+)", str(cfg.get(key, "")))
if match:
print(match.group(1))
PY
)
for port in $ports; do
local up=0
for _ in $(seq 1 120); do
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
exec 3>&- 3<&-; up=1
break
fi
sleep 1
done
# Fail clearly instead of letting reinstall die later on a vague socket-connection error
# when redis never bound.
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; }
done
}
wait_for_redis
# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python
# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test
# shards start the DB on the baked datadir instead of repeating the reinstall.
run_ci_step "Reinstall test site" bench --site test_site reinstall --yes

241
.github/helper/postgres_compat.py vendored Executable file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Static guard against MySQL-only SQL that breaks on PostgreSQL.
The Postgres test job is label-gated, so it does not run on every PR. This pre-commit
hook is the always-on first line of defence: it flags the *mechanical* Postgres breaks
that static analysis can catch reliably with a low false-positive rate.
It deliberately does NOT try to catch the *semantic* divergences (loose GROUP BY,
case-sensitive ==/IN, NULL ordering, ORDER BY ... LIMIT 1 tiebreakers, integer-division
intent, division by a possibly-zero divisor, savepoint discipline) — those genuinely need
the test suite or a human/Greptile reviewer. Run the full suite on a Postgres site for those.
Escape hatch: put `# pg-ok` anywhere on the offending statement's line span (e.g. on a
`SHOW INDEX` query that lives inside an `if frappe.db.db_type == "mariadb":` branch).
Usage: postgres_compat.py <file.py> [<file.py> ...] (pre-commit passes staged files)
"""
from __future__ import annotations
import ast
import re
import sys
IGNORE = "pg-ok"
# Strings are only scanned for the patterns below when they have real SQL *structure*
# (not just an English word like "select" or "from"), to keep false positives near zero.
SQL_HINT = re.compile(
r"\bselect\b[\s\S]{0,800}\bfrom\b" # SELECT ... FROM
r"|\bupdate\b[\s\S]{0,400}\bset\b" # UPDATE ... SET
r"|\bdelete\s+from\b"
r"|\binsert\s+into\b"
r"|\bshow\s+(?:index|tables|columns)\b"
r"|\bfrom\s+[\"'`]?tab", # FROM `tabDocType`
re.I,
)
# MySQL-only constructs with NO frappe auto-translation. (frappe.db.sql already rewrites
# ifnull->coalesce on all engines and backtick/locate/REGEXP on Postgres, and .like()
# renders ILIKE — so those are NOT listed here; flagging them would be false positives.)
SQL_PATTERNS: list[tuple[re.Pattern, str]] = [
(re.compile(r"\btimestamp\s*\(\s*[^,()]+,", re.I),
"timestamp(date, time) is MySQL-only -> use CombineDatetime() or a precomputed datetime column"),
(re.compile(r"\btimediff\s*\(", re.I),
"timediff() is MySQL-only -> compute the delta in Python"),
(re.compile(r"\bstr_to_date\s*\(", re.I),
"str_to_date() is MySQL-only -> parse in Python and pass a real date"),
(re.compile(r"\bdate_format\s*\(", re.I),
"date_format() is MySQL-only -> filter on a date range instead"),
(re.compile(r"\bdate_(add|sub)\s*\(", re.I),
"date_add()/date_sub() are MySQL-only -> use Python date math or interval arithmetic"),
(re.compile(r"\bgroup_concat\s*\(", re.I),
"group_concat() is MySQL-only -> use GroupConcat (string_agg) or aggregate in Python"),
(re.compile(r"\bperiod_diff\s*\(", re.I),
"period_diff() is MySQL-only -> compute in Python"),
(re.compile(r"\bshow\s+index\b", re.I),
"SHOW INDEX is MySQL-only -> use frappe.db.has_index() / get_column_index()"),
(re.compile(r"\bshow\s+(tables|columns)\b", re.I),
"SHOW TABLES/COLUMNS is MySQL-only -> use frappe.db.get_tables()/table_columns / information-schema helpers"),
(re.compile(r"\bas\s+'[^']+'", re.I),
"single-quoted column alias breaks on Postgres -> use a bare or double-quoted alias"),
(re.compile(r"\bif\s*\(", re.I),
"SQL IF() is MySQL-only -> use CASE WHEN ... THEN ... ELSE ... END (frappe.qb.Case())"),
(re.compile(r"\brlike\b", re.I),
"RLIKE is MySQL-only -> frappe rewrites REGEXP->~* on Postgres but NOT RLIKE; use REGEXP / .regexp() / ~"),
(re.compile(r"\bcast\s*\(.+?\bas\s+char\b", re.I | re.S), # .+? spans nested parens, e.g. CAST(ABS(x) AS CHAR)
"CAST(... AS CHAR) is character(1) on Postgres and truncates -> CAST AS VARCHAR (frappe Cast_(x, 'varchar'))"),
]
# UPDATE ... JOIN: both keywords in the same SQL string.
UPDATE_JOIN = (re.compile(r"\bupdate\b", re.I), re.compile(r"\bjoin\b", re.I))
MYSQL_RESULT_KEYS = {"Column_name", "Key_name", "Seq_in_index", "Non_unique", "Index_type"}
SET_BOOL_FUNCS = {"set_value", "db_set"}
# query-builder cast helpers: pypika Cast / frappe Cast_. A "char" target type is character(1)
# on Postgres (truncates); "varchar" is the full-length cast.
CAST_FUNCS = {"Cast", "Cast_"}
# frappe.get_all / get_list: frappe's db_query SILENTLY drops ORDER BY for `distinct` queries on
# Postgres (the ORDER BY column must appear in the SELECT-DISTINCT list), so `distinct=True` together
# with a literal `order_by` is a no-op on PG and the result comes back unordered.
DISTINCT_ORDER_FUNCS = {"get_all", "get_list"}
def _docstring_ids(tree: ast.AST) -> set[int]:
"""ids of Constant nodes that are docstrings (so prose describing the rules isn't flagged)."""
ids: set[int] = set()
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
body = getattr(node, "body", None)
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str):
ids.add(id(body[0].value))
return ids
class Visitor(ast.NodeVisitor):
def __init__(self, lines: list[str], docstrings: set[int]):
self.lines = lines
self.docstrings = docstrings
self.violations: list[tuple[int, str]] = []
def _ignored(self, node: ast.AST) -> bool:
start = getattr(node, "lineno", 1)
end = getattr(node, "end_lineno", start) or start
# honour `# pg-ok` anywhere on the node's line span, the line just above (the enclosing
# call, e.g. `frappe.db.sql( # pg-ok`), or the line just below (a multi-line call's `) # pg-ok`).
lo = max(0, start - 2)
return any(IGNORE in self.lines[i] for i in range(lo, min(end + 1, len(self.lines))))
def _flag(self, node: ast.AST, msg: str) -> None:
if not self._ignored(node):
self.violations.append((getattr(node, "lineno", 1), msg))
def _scan_sql(self, text: str, node: ast.AST) -> None:
if not SQL_HINT.search(text):
return
for pattern, msg in SQL_PATTERNS:
if pattern.search(text):
self._flag(node, msg)
if UPDATE_JOIN[0].search(text) and UPDATE_JOIN[1].search(text):
self._flag(node, "UPDATE ... JOIN is MySQL-only -> use a correlated subquery (WHERE ... IN/EXISTS)")
def visit_Constant(self, node: ast.Constant) -> None:
# plain string literals, incl. `"...".format()` and `"..." % (...)` templates
if isinstance(node.value, str) and id(node) not in self.docstrings:
self._scan_sql(node.value, node)
self.generic_visit(node)
def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
# f-string: scan its STATIC text (interpolated values become a placeholder) so MySQL-isms
# in dynamic SQL are caught, without flagging safe interpolation of identifiers.
text = "".join(
v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else " ? "
for v in node.values
)
self._scan_sql(text, node)
# don't recurse: child literal chunks would otherwise be re-scanned individually
def visit_Call(self, node: ast.Call) -> None:
fn = node.func
name = fn.attr if isinstance(fn, ast.Attribute) else (fn.id if isinstance(fn, ast.Name) else "")
# row.get("Column_name") — MySQL SHOW INDEX result key
if name == "get" and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value in MYSQL_RESULT_KEYS:
self._flag(node, f'"{node.args[0].value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
# set_value(..., True) / db_set("field", True) on a Check (int) column.
# Only the field *value* arg carries bool->smallint risk — NOT trailing flags like
# update_modified. db_set(field, value, update_modified, ...) -> value at args[1] (or a dict
# at args[0]); set_value(dt, dn, field, value, ...) -> value at args[3] (or a dict at args[2]).
if name in SET_BOOL_FUNCS:
value_idx, dict_idx = (1, 0) if name == "db_set" else (3, 2)
dict_arg = (
node.args[dict_idx]
if len(node.args) > dict_idx and isinstance(node.args[dict_idx], ast.Dict)
else None
)
if dict_arg is not None:
for v in dict_arg.values:
if isinstance(v, ast.Constant) and isinstance(v.value, bool):
self._flag(node, f"{name}(...) sets an int/Check column with a bool in a dict -> pass 1/0 (Postgres rejects bool->smallint)")
elif len(node.args) > value_idx:
a = node.args[value_idx]
if isinstance(a, ast.Constant) and isinstance(a.value, bool):
self._flag(node, f"{name}(..., {a.value}) sets an int/Check column with a bool -> pass 1/0 (Postgres rejects bool->smallint)")
# frappe.get_all/get_list(..., distinct=True, order_by="<col>") -> ORDER BY is silently dropped
# for distinct queries on Postgres, so the result is unordered there. Sort in python instead
# (e.g. sorted(frappe.get_all(..., distinct=True), key=str.casefold)). An empty order_by="" (the
# explicit "suppress the injected default" idiom) and a dynamic/variable order_by are not flagged.
if name in DISTINCT_ORDER_FUNCS:
has_distinct = any(
kw.arg == "distinct" and isinstance(kw.value, ast.Constant) and kw.value.value
for kw in node.keywords
)
order_kw = next((kw for kw in node.keywords if kw.arg == "order_by"), None)
has_literal_order = (
order_kw is not None
and isinstance(order_kw.value, ast.Constant)
and isinstance(order_kw.value.value, str)
and order_kw.value.value.strip()
)
if has_distinct and has_literal_order:
self._flag(node, f"{name}(distinct=True, order_by=...) -> frappe drops ORDER BY for distinct queries on Postgres; sort in python instead, e.g. sorted(..., key=str.casefold)")
# query-builder .rlike(...): pypika emits the MySQL-only RLIKE operator, which frappe does
# NOT translate for Postgres (it rewrites only REGEXP -> ~*).
if name == "rlike":
self._flag(node, ".rlike() emits MySQL-only RLIKE (not translated on Postgres) -> use .regexp() (rewritten to ~*) or .like()")
# Cast(col, "char") / Cast_(col, "char"): on Postgres a bare CHAR is character(1) and truncates
# (e.g. CAST(12 AS CHAR) -> '1'); use "varchar" for a full-length string cast.
if name in CAST_FUNCS:
for arg in (*node.args, *(kw.value for kw in node.keywords)):
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.strip().lower() == "char":
self._flag(node, f"{name}(..., 'char') is character(1) on Postgres and truncates -> use 'varchar'")
self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> None:
key = node.slice
if isinstance(key, ast.Constant) and key.value in MYSQL_RESULT_KEYS:
self._flag(node, f'"{key.value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
self.generic_visit(node)
def check_file(path: str) -> list[str]:
try:
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-security-file-traversal -- dev-only lint tool; `path` is a source file supplied by pre-commit, not user input
src = open(path, encoding="utf-8").read()
except (OSError, UnicodeDecodeError):
return []
try:
tree = ast.parse(src, filename=path)
except SyntaxError:
return [] # check-ast hook reports real syntax errors
v = Visitor(src.splitlines(), _docstring_ids(tree))
v.visit(tree)
return [f"{path}:{line}: [pg-compat] {msg}" for line, msg in sorted(set(v.violations))]
def main(argv: list[str]) -> int:
out: list[str] = []
for path in argv:
if path.endswith(".py"):
out.extend(check_file(path))
if out:
print("\n".join(out))
print(
f"\n{len(out)} PostgreSQL-incompatibility issue(s). Fix them, or add `# pg-ok` to a "
"line that is intentionally MariaDB-only (e.g. inside an `if frappe.db.db_type == 'mariadb':` branch)."
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -13,6 +13,6 @@
"root_login": "postgres",
"root_password": "travis",
"host_name": "http://test_site:8000",
"install_apps": ["erpnext"],
"install_apps": ["payments", "erpnext"],
"throttle_user_limit": 100
}

79
.github/helper/start-db.sh vendored Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
#
# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be
# packaged into the bench artifact, test shards start an already-loaded server instead of
# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept.
#
# CI_DB_DATADIR picks the path:
# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench`
# doesn't wipe it; it's moved into the bench just before packaging)
# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it)
#
# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards).
#
set -e
ci_user="${ERPNEXT_CI_USER:-frappe}"
# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is
# refused anyway). Mirrors install.sh's user switch.
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
exec su -m "$ci_user" -s /bin/bash -c \
"ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' DB='${DB:-}' bash '$0'"
fi
# --- PostgreSQL (GitHub-hosted CI): run in-runner on a PGDATA so it bakes into the artifact,
# same idea as the mariadb datadir. Trust auth (throwaway CI) skips password setup; durability
# off for speed. Postgres is preinstalled on ubuntu-latest under /usr/lib/postgresql/<ver>/bin.
if [ "${DB:-mariadb}" = "postgres" ]; then
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin 2>/dev/null | sort -V | tail -1)
[ -n "$PG_BIN" ] && export PATH="$PG_BIN:$PATH"
PGDATA="${CI_DB_DATADIR:-$HOME/frappe-bench/pgdata}"
if [ ! -d "$PGDATA/base" ]; then
initdb -D "$PGDATA" -U postgres --auth-local=trust --auth-host=trust >/dev/null
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
fi
pg_ctl -D "$PGDATA" -w -o "-p 5432 -c listen_addresses=127.0.0.1 -c unix_socket_directories=$PGDATA -c fsync=off -c synchronous_commit=off -c full_page_writes=off" start
echo "PostgreSQL up in-runner (pgdata=$PGDATA)"
exit 0
fi
# --- MariaDB ---
DATADIR="${CI_DB_DATADIR:-$HOME/frappe-bench/mariadb-data}"
SOCK="$DATADIR/mysqld.sock"
fresh=0
if [ ! -d "$DATADIR/mysql" ]; then
mkdir -p "$DATADIR"
mariadb-install-db --no-defaults --datadir="$DATADIR" \
--auth-root-authentication-method=normal --skip-test-db >/dev/null 2>&1
fresh=1
fi
# Throwaway-CI durability off; bind TCP 127.0.0.1:3306 so bench/install.sh connect as usual.
mariadbd --no-defaults --datadir="$DATADIR" --socket="$SOCK" --pid-file="$DATADIR/mysqld.pid" \
--port=3306 --bind-address=127.0.0.1 \
--innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-log-bin \
> "$HOME/mariadb.log" 2>&1 &
up=0
for _ in $(seq 1 60); do
if mariadb-admin --socket="$SOCK" ping --silent 2>/dev/null; then up=1; break; fi
sleep 1
done
# Fail loudly instead of letting the loop fall through (exit 0 of the last `sleep`) into SQL that
# would error with a vague socket-connection failure.
[ "$up" = "1" ] || { echo "mariadbd did not come up on $SOCK"; cat "$HOME/mariadb.log" 2>/dev/null; exit 1; }
if [ "$fresh" = "1" ]; then
# A fresh datadir has only a password-less root@localhost. Give it the password install.sh
# uses, plus a TCP-reachable root@127.0.0.1, so the rest of install.sh works unchanged.
mariadb --no-defaults --socket="$SOCK" -u root <<'SQL'
ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
CREATE USER IF NOT EXISTS 'root'@'127.0.0.1' IDENTIFIED BY 'root';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SQL
fi
echo "MariaDB up in-container (datadir=$DATADIR, fresh=$fresh)"

View File

@@ -65,6 +65,19 @@ jobs:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
# ~100MB from frappe.io every run.
- name: Cache erpnext v14 backup
id: cache-v14
uses: actions/cache@v4
with:
path: ~/erpnext-v14.sql.gz
key: erpnext-v14-sql-gz
- name: Download erpnext v14 backup
if: steps.cache-v14.outputs.cache-hit != 'true'
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
- name: Cache pip
uses: actions/cache@v4
with:
@@ -113,12 +126,20 @@ jobs:
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
wget https://frappe.io/files/erpnext-v14.sql.gz
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
bench --site test_site --force restore ~/erpnext-v14.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
# Start every bench process except the background workers. If workers run during a
# migrate, they pick up the orphan-link cleanup jobs it enqueues and race its schema
# changes, which fails with MySQL 1412 "Table definition has changed". Redis and the
# other services stay up; the queued jobs simply wait and are harmless here.
function start_bench_without_workers() {
local procs
procs=$(awk -F: '/^[a-z_]+:/ && $1 !~ /worker/ {print $1}' ~/frappe-bench/Procfile)
honcho start -f ~/frappe-bench/Procfile $procs &>> ~/frappe-bench/bench_start.log &
}
function update_to_version() {
version=$1
@@ -134,10 +155,11 @@ jobs:
# Resetup env and install apps
pgrep honcho | xargs kill
sleep 10
rm -rf ~/frappe-bench/env
bench -v setup env --python python$2
bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
start_bench_without_workers
bench --site test_site migrate
}
@@ -154,7 +176,7 @@ jobs:
rm -rf ~/frappe-bench/env
bench -v setup env
bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
start_bench_without_workers
bench --site test_site migrate

View File

@@ -0,0 +1,25 @@
name: Review translation PRs
description: "Posts review comments with relevant translation changes that are hard to inspect in the diff view."
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- "**/*.po"
- "**/*.pot"
concurrency:
group: po-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review-po-pr:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: alyf-de/po-review-action@v1.1.0

View File

@@ -31,47 +31,49 @@ on:
permissions:
contents: read
packages: read
concurrency:
group: server-mariadb-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: true
# Shared across both jobs. Both run in the SAME CI image so the bench lives at the identical
# path (/home/ci/frappe-bench) on the setup runner and the test shards — that's what makes the
# packaged Python venv portable between them.
env:
TZ: 'Asia/Kolkata'
DEBIAN_FRONTEND: noninteractive
NODE_ENV: "production"
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
ERPNEXT_CI_USER: ci
PIP_CACHE_DIR: /home/ci/.cache/pip
npm_config_cache: /home/ci/.cache/npm
YARN_CACHE_FOLDER: /home/ci/.cache/yarn
UV_CACHE_DIR: /home/ci/.cache/uv
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
TZ: 'Asia/Kolkata'
NODE_ENV: "production"
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
container: [1, 2, 3, 4]
name: Python Unit Tests
services:
mysql:
image: mariadb:10.6
env:
TZ: 'Asia/Kolkata'
MARIADB_ROOT_PASSWORD: 'root'
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
# Build the bench (clone + pip + yarn + assets) and reinstall test_site ONCE, on a free
# GitHub-hosted runner, then publish the whole bench (with a DB dump baked in) as an artifact.
# The expensive, non-parallelisable work happens here exactly once instead of on every shard.
setup:
name: Build & reinstall (setup)
# Dedicated scale set (fat cpu request) so the build+reinstall runs at full speed, uncontended
# by the many thin test shards. Same CI image + /home/ci path + 127.0.0.1 DB as the shards,
# so the packaged bench (and its venv) transplants cleanly.
runs-on: erpnext-arc-setup
timeout-minutes: 40
container:
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
credentials:
username: ${{ secrets.GHCR_USERNAME || github.actor }}
password: ${{ secrets.GHCR_TOKEN || github.token }}
defaults:
run:
shell: bash
steps:
- name: Clone
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Check for valid Python & Merge Conflicts
run: |
python -m compileall -fq "${GITHUB_WORKSPACE}"
@@ -80,47 +82,17 @@ jobs:
exit 1
fi
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
check-latest: true
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Cache node modules
uses: actions/cache@v4
# MariaDB runs in-container on a datadir OUTSIDE the bench, because install.sh's next step
# does `rm -rf ~/frappe-bench`. After the reinstall, the datadir is moved into the bench so
# it ships in the artifact — test shards then start an already-loaded server (no restore).
- name: Start DB
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
SKIP_SYSTEM_SETUP: "1"
CI_DB_DATADIR: /home/ci/db-data
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
@@ -129,26 +101,107 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
DB_HOST: 127.0.0.1
DB_USER_HOST: '%'
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
SKIP_SYSTEM_SETUP: "1"
SKIP_WKHTMLTOX_SETUP: "1"
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
- name: Stop DB and stage datadir
run: |
mariadb-admin -h 127.0.0.1 -P 3306 -u root -proot shutdown || true
for _ in $(seq 1 30); do [ -f /home/ci/db-data/mysqld.pid ] || break; sleep 1; done
# Don't bake a dirty datadir — fail if mariadbd didn't finish stopping, rather than ship
# an inconsistent datadir the shards would have to crash-recover.
[ -f /home/ci/db-data/mysqld.pid ] && { echo "mariadbd did not shut down cleanly"; exit 1; }
mv /home/ci/db-data /home/ci/frappe-bench/mariadb-data
# Package the whole bench (apps, venv, node_modules, sites, the DB dump, and hydrate.sh)
# into one artifact for the test shards to consume.
# Single-node hand-off: stage the bench on a node-local hostPath instead of round-tripping
# through GitHub artifact storage (~60s/shard). Setup and shards share the same disk, so
# the shards just untar it locally. NOTE: this assumes one node — a shard on a different
# node could not read this path (then you'd need GitHub artifacts or an NFS/RWX volume).
- name: Stage bench on node (hostPath)
run: |
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/ci/frappe-bench/hydrate.sh
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/ci/frappe-bench/start-db.sh
mkdir -p /opt/ci-bench-staging
# self-clean: drop bench tars from runs older than 2h
find /opt/ci-bench-staging -maxdepth 1 -name '*.tar.gz' -mmin +120 -delete 2>/dev/null || true
# Exclude .git/node_modules; the mariadb-data datadir IS included (the pre-loaded DB).
tar czpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci \
--exclude='.git' --exclude='node_modules' frappe-bench
ls -lh "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz"
# Fan-out: each shard downloads the bench, untars it, starts MariaDB on the baked datadir, and
# runs its slice of the suite. No clone, no build, no reinstall, no DB dump restore on the shards.
test:
name: Python Unit Tests
needs: setup
runs-on: erpnext-arc
timeout-minutes: 60
container:
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
credentials:
username: ${{ secrets.GHCR_USERNAME || github.actor }}
password: ${{ secrets.GHCR_TOKEN || github.token }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
container: [1, 2, 3, 4]
steps:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# Read the bench straight from the node-local hostPath the setup job staged it on — no
# GitHub download. -p preserves the ci (uid 1001) ownership so bench runs as ci cleanly.
- name: Untar bench from node (hostPath)
run: |
tar xzpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci
ls -ld /home/ci/frappe-bench
- name: Hydrate (start DB on baked datadir + bench start)
run: bash /home/ci/frappe-bench/hydrate.sh
env:
DB_HOST: 127.0.0.1
SKIP_SYSTEM_SETUP: "1"
- name: Run Tests
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
run: |
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
cd ~/frappe-bench/
coverage_flag=""
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
bench --site test_site run-parallel-tests --lightmode --app erpnext \
--total-builds ${{ strategy.job-total }} \
--build-number ${{ matrix.container }} \
$coverage_flag
EOF
env:
TYPE: server
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
- name: Upload coverage data
if: ${{ env.WITH_COVERAGE == 'true' }}
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
path: /home/ci/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: test
needs: [test]
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone

View File

@@ -1,7 +1,12 @@
name: Server (Postgres)
on:
schedule:
# 03:00 AM IST daily (21:30 UTC the previous day)
- cron: "30 21 * * *"
pull_request:
# 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run.
types: [opened, reopened, synchronize, labeled]
paths-ignore:
- '**.js'
- '**.md'
@@ -9,7 +14,7 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
types: [opened, labelled, synchronize, reopened]
workflow_dispatch:
concurrency:
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
@@ -18,41 +23,31 @@ concurrency:
permissions:
contents: read
# Postgres CI stays on GitHub-hosted (free, full-speed VM per shard) but follows the same fan-out
# we built for MariaDB: build the bench + reinstall ONCE in the setup job, bake the PostgreSQL
# PGDATA into the artifact, and have 4 test shards start Postgres on that datadir — no per-shard
# clone/build/reinstall/restore. Python is pinned so the venv transplants between VMs.
env:
TZ: 'Asia/Kolkata'
NODE_ENV: "production"
PYTHON_VERSION: '3.14'
jobs:
test:
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
setup:
name: Build & reinstall (setup)
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
container: [1]
name: Python Unit Tests
services:
postgres:
image: postgres:13.3
env:
POSTGRES_PASSWORD: travis
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
# Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries
# the 'postgres' label — the test job needs setup, so it's skipped too when this is.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres')
timeout-minutes: 40
steps:
- name: Clone
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
python-version: ${{ env.PYTHON_VERSION }}
- name: Check for valid Python & Merge Conflicts
run: |
@@ -71,48 +66,124 @@ jobs:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
- name: Cache deps (uv/pip/npm/yarn)
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
path: |
~/.cache/uv
~/.cache/pip
~/.npm
~/.cache/yarn
key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
restore-keys: ${{ runner.os }}-deps-
- name: Cache node modules
# Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env +
# node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only
# fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and
# only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted
# box. The first run after a deps change populates it; every run after that is fast.
- name: Cache warm bench (base build)
uses: actions/cache@v4
with:
path: ~/bench-cache
key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
restore-keys: ${{ runner.os }}-warmbench-v2-
# Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench);
# after the reinstall it's moved into the bench so it ships in the artifact.
- name: Start DB
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
DB: postgres
CI_DB_DATADIR: /home/runner/pgdata
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: postgres
TYPE: server
FRAPPE_BRANCH: develop
BENCH_CACHE_DIR: /home/runner/bench-cache
- name: Stop DB and stage datadir
run: |
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
- name: Package bench for test shards
run: |
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/runner/frappe-bench/hydrate.sh
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/runner/frappe-bench/start-db.sh
tar czpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner \
--exclude='.git' --exclude='node_modules' frappe-bench
ls -lh "${GITHUB_WORKSPACE}/bench.tar.gz"
- name: Upload bench artifact
uses: actions/upload-artifact@v4
with:
name: bench-pg
path: bench.tar.gz
retention-days: 1
compression-level: 0
test:
name: Python Unit Tests (PG)
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
container: [1, 2, 3, 4]
steps:
- name: Download bench artifact
uses: actions/download-artifact@v4
with:
name: bench-pg
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# The bench CLI (frappe-bench) and redis are global/system tools — not in the bench tarball.
# The setup runner got them via install.sh; the MariaDB shards get them from the arc5 image.
# GitHub-hosted PG shards install them here (cheap vs the build+reinstall that setup did once).
- name: Install shard runtime (bench CLI + redis + wkhtmltopdf)
run: |
pip install frappe-bench
command -v redis-server >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-server; }
# wkhtmltopdf (patched-qt build) for print-format / PDF tests — same .deb install.sh uses.
if ! command -v wkhtmltopdf >/dev/null; then
wget -qO /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
sudo apt-get install -y -qq /tmp/wkhtmltox.deb
fi
- name: Untar bench
run: |
tar xzpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner
ls -ld /home/runner/frappe-bench
- name: Hydrate (start Postgres on the baked datadir)
run: bash /home/runner/frappe-bench/hydrate.sh
env:
DB: postgres
DB_HOST: 127.0.0.1
- name: Run Tests
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
run: |
cd ~/frappe-bench/
# print-format / PDF tests are engine-independent (they exercise wkhtmltopdf rendering,
# not postgres SQL — the MariaDB CI already covers them). They only fetch the static asset
# bundles from http://test_site:8000/assets/..., so a plain static file server over sites/
# satisfies wkhtmltopdf without the frappe web server (which never bound on a bare runner).
( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & )
for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done
bench --site test_site run-parallel-tests --lightmode --app erpnext \
--total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}
env:
TYPE: server
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io

View File

@@ -16,6 +16,10 @@ on:
- cron: "0 10 * * 1"
workflow_dispatch:
# The runner dispatch uses RELEASE_TOKEN (a PAT), not the default GITHUB_TOKEN,
# so no GITHUB_TOKEN permissions are required.
permissions: {}
jobs:
trigger-runners:
name: Trigger sync → ${{ matrix.hotfix_branch }}

25
.greptile/config.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -66,6 +66,18 @@ repos:
- id: ruff-format
name: "Run ruff formatter"
- repo: local
hooks:
- id: postgres-compat
name: "PostgreSQL compatibility (static check)"
description: "Flags MySQL-only SQL that breaks on Postgres; the label-gated PG test job is the backstop for semantic divergences."
entry: .github/helper/postgres_compat.py
language: script
files: ^erpnext/.*\.py$
# patches/ are historical, version-gated migrations (skipped on fresh Postgres installs);
# out of scope for the always-on gate.
exclude: ^erpnext/patches/
ci:
autoupdate_schedule: weekly
skip: []

View File

@@ -48,7 +48,7 @@
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.11"
"vite": "^8.0.16"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

View File

@@ -250,7 +250,7 @@ const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { de
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
</DialogDescription>
</DialogHeader>
{error && <ErrorBanner error={error} />}
{error && <div className="py-2"><ErrorBanner error={error} /></div>}
<div className="py-4">
<CurrencyFormField
name="balance"

View File

@@ -2,9 +2,7 @@ import CSVRawDataPreview from './CSVRawDataPreview'
import StatementDetails from './StatementDetails'
import { GetStatementDetailsResponse } from '../import_utils'
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
const CSVImport = ({ data, mutate }: { data: { message: GetStatementDetailsResponse }, mutate: () => void }) => {
return (
<div className="w-full flex">
@@ -12,7 +10,7 @@ const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } })
<StatementDetails data={data.message} />
</div>
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
<CSVRawDataPreview data={data.message} />
<CSVRawDataPreview data={data.message} mutate={mutate} />
</div>
</div>
)

View File

@@ -1,151 +1,104 @@
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import _ from "@/lib/translate"
import { GetStatementDetailsResponse } from "../import_utils"
import { useMemo } from "react"
import RawTableGrid from "../RawTableGrid"
import {
applyColumnMappingChange,
ColumnMapsTo,
GetStatementDetailsResponse,
useSetHeaderIndex,
useUpdateColumnMapping,
} from "../import_utils"
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
type Mapping = Pick<BankStatementImportLogColumnMap, "index" | "maps_to" | "header_text" | "variable">
const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => {
const toMapping = (columns?: BankStatementImportLogColumnMap[]): Mapping[] =>
(columns ?? []).map((c) => ({
index: c.index,
maps_to: c.maps_to,
header_text: c.header_text,
variable: c.variable,
}))
const column_mapping: Record<StandardColumnTypes, number> = useMemo(() => {
const headerToState = (index?: number) => (index != null && index >= 0 ? index : null)
const col_map: Record<string, number> = {}
const CSVRawDataPreview = ({
data,
mutate,
}: {
data: GetStatementDetailsResponse
mutate: () => void
}) => {
const isCompleted = data.doc.status === "Completed"
data.doc.column_mapping?.forEach(col => {
if (col.maps_to && col.maps_to !== "Do not import") {
col_map[col.maps_to] = col.index;
}
})
const [mapping, setMapping] = useState<Mapping[]>(() => toMapping(data.doc.column_mapping))
const [headerIndex, setHeaderIndex] = useState<number | null>(() =>
headerToState(data.doc.detected_header_index),
)
return col_map
const { call: updateMapping, loading: savingMapping } = useUpdateColumnMapping()
const { call: setHeader, loading: savingHeader } = useSetHeaderIndex()
}, [data])
const mappingRef = useRef(mapping)
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const validColumns = Object.values(column_mapping)
useEffect(() => () => clearTimeout(saveTimer.current), [])
// Reverse the column mapping to get a map of column index to variable name
const columnIndexMap: Record<number, StandardColumnTypes> = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes]))
const columnMappingRecord: Record<number, ColumnMapsTo> = {}
mapping.forEach((c) => {
if (c.maps_to) columnMappingRecord[c.index] = c.maps_to as ColumnMapsTo
})
const commitMapping = (next: Mapping[]) => {
mappingRef.current = next
setMapping(next)
}
// Persist mapping edits (debounced) so the transaction preview updates in realtime.
const scheduleSaveMapping = () => {
if (isCompleted) return
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
updateMapping({ statement_import_id: data.doc.name, column_mapping: mappingRef.current })
.then(() => mutate())
.catch(() => toast.error(_("Could not save the column mapping.")))
}, 500)
}
const onChangeMapping = (columnIndex: number, mapsTo: ColumnMapsTo) => {
if (isCompleted) return
commitMapping(applyColumnMappingChange(mappingRef.current, columnIndex, mapsTo))
scheduleSaveMapping()
}
const onSetHeader = (rowIndex: number | null) => {
if (isCompleted) return
setHeaderIndex(rowIndex)
setHeader({ statement_import_id: data.doc.name, header_index: rowIndex ?? -1 })
.then((res) => {
// The backend re-derives the mapping for the new header; sync local state.
const doc = res?.message?.doc
if (doc) {
commitMapping(toMapping(doc.column_mapping))
setHeaderIndex(headerToState(doc.detected_header_index))
}
mutate()
})
.catch(() => toast.error(_("Could not update the header row.")))
}
// Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows
return (
<Table containerClassName="rounded-none">
<TableBody>
{data.raw_data.map((row, index) => {
const isHeaderRow = index === data.doc.detected_header_index;
const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0);
return <TableRow key={index}
title={isHeaderRow ? "Header Row" : ""}
className={cn({
// "bg-yellow-100": isHeaderRow,
// "hover:bg-yellow-100": isHeaderRow,
"bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700": isTransactionRow,
"text-ink-gray-5/70": !isTransactionRow && !isHeaderRow,
})}>
{isHeaderRow ? <TableHead className="bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400 text-center font-semibold text-ink-gray-8">
{index + 1}
</TableHead> :
<TableCell className="text-center px-1 py-0.5">
{index + 1}
</TableCell>
}
{row.map((cell, cellIndex) => {
const isValidColumn = validColumns.includes(cellIndex);
const columnType = columnIndexMap[cellIndex];
const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType);
if (isHeaderRow) {
return <TableHead key={cellIndex} className={cn("max-w-[250px] w-fit overflow-hidden text-ellipsis py-0.5",
isValidColumn ? "bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400" : "bg-surface-gray-2",
)}>
<div className={cn("flex items-center text-xs gap-1 px-1 text-ink-gray-8 font-medium", {
"justify-end": isAmountColumn && isValidColumn
})}>
{columnType && <Tooltip>
<TooltipTrigger>
<ColumnHeaderIcon columnType={columnType} />
</TooltipTrigger>
<TooltipContent>
{_(columnType)}
</TooltipContent>
</Tooltip>
}
{cell}
</div>
</TableHead>
} else {
return <TableCell key={cellIndex} className={cn("max-w-[200px] w-fit overflow-hidden text-ellipsis py-0.5",
{
"bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400": isValidColumn && isTransactionRow,
"text-ink-gray-5": !isValidColumn && isTransactionRow,
}
)} >
<div className={cn("min-h-5 flex items-center text-xs px-1", {
"justify-end": isAmountColumn && isValidColumn && isTransactionRow
})} title={cell}>
{cell}
</div>
</TableCell>
}
}
)}
</TableRow>
})}
</TableBody>
</Table >
<RawTableGrid
rows={data.raw_data}
columnMapping={columnMappingRecord}
headerIndex={headerIndex}
editable={!isCompleted}
disabled={isCompleted || savingMapping || savingHeader}
onChangeMapping={onChangeMapping}
onSetHeader={onSetHeader}
/>
)
}
type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to'];
const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => {
if (!columnType) {
return null
}
if (columnType === 'Amount') {
return <DollarSignIcon className="w-4 h-4" />
}
if (columnType === 'Withdrawal') {
return <ArrowUpRightIcon className="w-4 h-4 text-ink-red-3" />
}
if (columnType === 'Deposit') {
return <ArrowDownRightIcon className="w-4 h-4 text-ink-green-3" />
}
if (columnType === 'Balance') {
return <BanknoteIcon className="w-4 h-4" />
}
if (columnType === 'Date') {
return <CalendarIcon className="w-4 h-4" />
}
if (columnType === 'Description') {
return <FileTextIcon className="w-4 h-4" />
}
if (columnType === 'Reference') {
return <ReceiptIcon className="w-4 h-4" />
}
if (columnType === 'Transaction Type') {
return <ListIcon className="w-4 h-4" />
}
if (columnType === 'Debit/Credit') {
return <ArrowUpDownIcon className="w-4 h-4" />
}
return null
}
export default CSVRawDataPreview
export default CSVRawDataPreview

View File

@@ -142,11 +142,16 @@ const StatementDetails = ({ data }: Props) => {
<TableCell>
<div className='flex items-center gap-2'>
<BankLogo bank={bank} />
<span className="tracking-tight text-sm font-medium">{bank?.account_name}</span>
<span title="GL Account" className="text-sm">{bank?.account}</span>
<span className="text-sm">{bank?.account_name}</span>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Account")}</TableHead>
<TableCell>
<span title="GL Account" className="text-sm">{bank?.account}</span>
</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Statement File")}</TableHead>
<TableCell>
@@ -158,7 +163,11 @@ const StatementDetails = ({ data }: Props) => {
</TableRow>
<TableRow>
<TableHead>{_("Transaction Dates")}</TableHead>
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
{data.doc.start_date && data.doc.end_date ? (
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
) : (
<TableCell>-</TableCell>
)}
</TableRow>
<TableRow>
<TableHead>{_("Number of Transactions")}</TableHead>

View File

@@ -0,0 +1,129 @@
import { RefObject, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
type Bbox = [number, number, number, number]
const MIN_SIZE = 8 // PDF points
// Keep the box valid: normalise flipped edges, enforce a min size, clamp to the page.
const clampBbox = (bbox: Bbox, pageWidth: number, pageHeight: number): Bbox => {
let [x0, top, x1, bottom] = bbox
if (x1 < x0) [x0, x1] = [x1, x0]
if (bottom < top) [top, bottom] = [bottom, top]
x0 = Math.max(0, Math.min(x0, pageWidth - MIN_SIZE))
top = Math.max(0, Math.min(top, pageHeight - MIN_SIZE))
x1 = Math.min(pageWidth, Math.max(x1, x0 + MIN_SIZE))
bottom = Math.min(pageHeight, Math.max(bottom, top + MIN_SIZE))
return [x0, top, x1, bottom]
}
const HANDLES = [
{ id: 'nw', className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize' },
{ id: 'ne', className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize' },
{ id: 'sw', className: 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize' },
{ id: 'se', className: 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize' },
]
type Props = {
bbox: Bbox
pageWidth: number
pageHeight: number
color: { border: string; bg: string; swatch: string }
label: string
included: boolean
disabled?: boolean
containerRef: RefObject<HTMLDivElement | null>
onCommit: (bbox: Bbox) => void
}
/** A draggable + corner-resizable rectangle over a rendered PDF page. Coordinates are in PDF
* points (top-left origin); pixel deltas are converted using the container's rendered size. */
const BBoxOverlay = ({ bbox, pageWidth, pageHeight, color, label, included, disabled, containerRef, onCommit }: Props) => {
const [draft, setDraft] = useState<Bbox>(bbox)
const draftRef = useRef<Bbox>(bbox)
const drag = useRef<{ mode: string; startX: number; startY: number; start: Bbox } | null>(null)
// Reset to the authoritative bbox whenever it changes (e.g. after a server re-extract).
useEffect(() => {
setDraft(bbox)
draftRef.current = bbox
}, [bbox])
const apply = (next: Bbox) => {
draftRef.current = next
setDraft(next)
}
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return
e.preventDefault()
e.stopPropagation()
const mode = (e.target as HTMLElement).dataset.handle ?? 'move'
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
drag.current = { mode, startX: e.clientX, startY: e.clientY, start: draftRef.current }
}
const onPointerMove = (e: React.PointerEvent) => {
if (!drag.current || !containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const dx = ((e.clientX - drag.current.startX) / rect.width) * pageWidth
const dy = ((e.clientY - drag.current.startY) / rect.height) * pageHeight
let [x0, top, x1, bottom] = drag.current.start
const m = drag.current.mode
if (m === 'move') {
x0 += dx
x1 += dx
top += dy
bottom += dy
} else {
if (m.includes('w')) x0 += dx
if (m.includes('e')) x1 += dx
if (m.includes('n')) top += dy
if (m.includes('s')) bottom += dy
}
apply(clampBbox([x0, top, x1, bottom], pageWidth, pageHeight))
}
const onPointerUp = (e: React.PointerEvent) => {
if (!drag.current) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
drag.current = null
onCommit(draftRef.current)
}
const [x0, top, x1, bottom] = draft
return (
<div
className={cn(
'absolute touch-none border-2',
color.border,
included ? color.bg : 'opacity-40',
disabled ? 'pointer-events-none' : 'cursor-move',
)}
style={{
left: `${(x0 / pageWidth) * 100}%`,
top: `${(top / pageHeight) * 100}%`,
width: `${((x1 - x0) / pageWidth) * 100}%`,
height: `${((bottom - top) / pageHeight) * 100}%`,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<span className={cn('pointer-events-none absolute -top-5 left-0 rounded px-1 text-[10px] font-medium text-white', color.swatch)}>
{label}
</span>
{!disabled &&
HANDLES.map((handle) => (
<span
key={handle.id}
data-handle={handle.id}
className={cn('absolute size-2.5 rounded-sm border border-white', color.swatch, handle.className)}
/>
))}
</div>
)
}
export default BBoxOverlay

View File

@@ -0,0 +1,23 @@
import StatementDetails from '../CSV/StatementDetails'
import PDFTableEditor from './PDFTableEditor'
import { GetStatementDetailsResponse } from '../import_utils'
type Props = {
data: { message: GetStatementDetailsResponse }
mutate: () => void
}
const PDFImport = ({ data, mutate }: Props) => {
return (
<div className="w-full flex">
<div className="w-[45%] p-4 h-[calc(100vh-72px)] overflow-scroll">
<StatementDetails data={data.message} />
</div>
<div className="w-[55%] border-s pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
<PDFTableEditor data={data.message} mutate={mutate} />
</div>
</div>
)
}
export default PDFImport

View File

@@ -0,0 +1,362 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, FileTextIcon, Loader2Icon, TableIcon } from 'lucide-react'
import _ from '@/lib/translate'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { H3, Paragraph } from '@/components/ui/typography'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import ErrorBanner from '@/components/ui/error-banner'
import RawTableGrid from '../RawTableGrid'
import BBoxOverlay from './BBoxOverlay'
import {
applyColumnMappingChange,
ColumnMapsTo,
GetStatementDetailsResponse,
PDFTable,
useReextractPDFTable,
useSetPDFTableHeader,
useUpdatePDFTables,
} from '../import_utils'
type Props = {
data: GetStatementDetailsResponse
mutate: () => void
}
// Distinct overlay colours per table on a page.
const OVERLAY_COLORS = [
{ border: 'border-blue-500', bg: 'bg-blue-500/10', swatch: 'bg-blue-500' },
{ border: 'border-purple-500', bg: 'bg-purple-500/10', swatch: 'bg-purple-500' },
{ border: 'border-amber-500', bg: 'bg-amber-500/10', swatch: 'bg-amber-500' },
{ border: 'border-teal-500', bg: 'bg-teal-500/10', swatch: 'bg-teal-500' },
]
const columnMappingRecord = (table: PDFTable): Record<number, ColumnMapsTo> => {
const map: Record<number, ColumnMapsTo> = {}
table.column_mapping?.forEach((col) => {
map[col.index] = col.maps_to
})
return map
}
const PDFTableEditor = ({ data, mutate }: Props) => {
const isCompleted = data.doc.status === 'Completed'
const [tables, setTables] = useState<PDFTable[]>(() => data.pdf_tables ?? [])
const [viewMode, setViewMode] = useState<'pdf' | 'table'>('pdf')
const [pageIndex, setPageIndex] = useState(0)
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
const toggleCollapsed = (tableIndex: number) =>
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(tableIndex)) {
next.delete(tableIndex)
} else {
next.add(tableIndex)
}
return next
})
const { call, loading, error } = useUpdatePDFTables()
const { call: reextract, loading: reextracting } = useReextractPDFTable()
const { call: setHeaderCall, loading: settingHeader } = useSetPDFTableHeader()
const busy = loading || reextracting || settingHeader
// Persist edits automatically (debounced) so the transaction preview updates in realtime.
const tablesRef = useRef(tables)
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const reextractTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const scheduleSave = () => {
if (isCompleted) return
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
call({ statement_import_id: data.doc.name, tables: tablesRef.current })
.then(() => mutate())
.catch(() => toast.error(_('Could not save the table settings.')))
}, 500)
}
// After a bbox change, re-extract that table's rows from the new region (debounced).
// The target is read inside the timeout so it always reflects the committed bbox.
const scheduleReextract = (tableIndex: number) => {
if (isCompleted) return
clearTimeout(reextractTimer.current)
reextractTimer.current = setTimeout(() => {
const target = tablesRef.current[tableIndex]
reextract({
statement_import_id: data.doc.name,
page: target.page,
table_index: target.table_index,
bbox: target.bbox,
})
.then((res) => {
commitTables(res?.message?.pdf_tables ?? [])
mutate()
})
.catch(() => toast.error(_('Could not re-extract the table.')))
}, 500)
}
useEffect(() => () => {
clearTimeout(saveTimer.current)
clearTimeout(reextractTimer.current)
}, [])
const pages = useMemo(() => Array.from(new Set(tables.map((t) => t.page))).sort((a, b) => a - b), [tables])
const currentPage = pages[pageIndex]
// Keep the table's position in the flat array so edits target the right one.
const pageTables = useMemo(
() => tables.map((table, index) => ({ table, index })).filter((t) => t.table.page === currentPage),
[tables, currentPage],
)
// Keep tablesRef in sync synchronously so the debounced save/re-extract never read stale state.
const commitTables = (next: PDFTable[]) => {
tablesRef.current = next
setTables(next)
}
const updateTable = (tableIndex: number, updater: (table: PDFTable) => PDFTable) => {
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? updater(t) : t)))
scheduleSave()
}
const onChangeMapping = (tableIndex: number, columnIndex: number, mapsTo: ColumnMapsTo) => {
updateTable(tableIndex, (table) => ({
...table,
column_mapping: applyColumnMappingChange(table.column_mapping, columnIndex, mapsTo),
}))
}
const onToggleIncluded = (tableIndex: number, included: boolean) =>
updateTable(tableIndex, (table) => ({ ...table, included }))
const onBboxCommit = (tableIndex: number, bbox: [number, number, number, number]) => {
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, bbox } : t)))
scheduleReextract(tableIndex)
}
// Set/clear the header row of a table; the backend re-derives the column mapping.
const onSetHeader = (tableIndex: number, headerIndex: number | null) => {
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, header_index: headerIndex } : t)))
const target = tablesRef.current[tableIndex]
setHeaderCall({
statement_import_id: data.doc.name,
page: target.page,
table_index: target.table_index,
header_index: headerIndex ?? -1,
})
.then((res) => {
commitTables(res?.message?.pdf_tables ?? [])
mutate()
})
.catch(() => toast.error(_('Could not update the header row.')))
}
if (tables.length === 0) {
return (
<div className="p-4">
<Paragraph className="text-p-sm text-ink-gray-5">
{_('No tables were extracted from this PDF.')}
</Paragraph>
</div>
)
}
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex flex-col gap-1">
<H3 className="text-base border-0 p-0">{_('Detected Tables')}</H3>
<Paragraph className="text-p-sm">
{_('Review each page. In the Table view, map each column, click a row number to set/clear the header row, and exclude anything that is not transactions (ads, summaries).')}
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}
<div className="flex items-center justify-between gap-2">
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'pdf' | 'table')}>
<TabsList variant="subtle">
<TabsTrigger value="pdf"><FileTextIcon />{_('PDF')}</TabsTrigger>
<TabsTrigger value="table"><TableIcon />{_('Table')}</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-1">
{busy && (
<span className="flex items-center gap-1 pe-1 text-xs text-ink-gray-5">
<Loader2Icon className="size-3 animate-spin" />
{reextracting ? _('Re-extracting') : _('Saving')}
</span>
)}
<Button
variant="ghost"
isIconButton
disabled={pageIndex === 0}
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="min-w-24 text-center text-sm text-ink-gray-7">
{_('Page {0} of {1}', [currentPage.toString(), pages.length.toString()])}
</span>
<Button
variant="ghost"
isIconButton
disabled={pageIndex >= pages.length - 1}
onClick={() => setPageIndex((i) => Math.min(pages.length - 1, i + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{viewMode === 'pdf' ? (
<PageView
pageTables={pageTables}
disabled={isCompleted}
onToggleIncluded={onToggleIncluded}
onBboxCommit={onBboxCommit}
/>
) : (
<div className="flex flex-col gap-4">
{pageTables.map(({ table, index }, position) => {
const isCollapsed = collapsed.has(index)
return (
<div
key={index}
className={cn('flex flex-col rounded border border-outline-gray-2', !table.included && 'opacity-60')}
>
<div className="flex items-center justify-between p-2">
<span className="ps-1 text-sm font-medium text-ink-gray-8">
{_('Table {0}', [(position + 1).toString()])}
</span>
<div className="flex items-center gap-2">
<IncludeToggle
id={`tbl-${index}`}
checked={table.included}
disabled={isCompleted}
onCheckedChange={(c) => onToggleIncluded(index, c)}
/>
<Button variant="ghost" size="sm" isIconButton onClick={() => toggleCollapsed(index)}>
<ChevronDownIcon className={cn('transition-transform', isCollapsed && '-rotate-90')} />
</Button>
</div>
</div>
{!isCollapsed && (
<div className="overflow-auto border-t border-outline-gray-2">
<RawTableGrid
rows={table.rows}
columnMapping={columnMappingRecord(table)}
headerIndex={table.header_index}
editable
disabled={isCompleted}
onChangeMapping={(columnIndex, mapsTo) => onChangeMapping(index, columnIndex, mapsTo)}
onSetHeader={(rowIndex) => onSetHeader(index, rowIndex)}
/>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
type PageViewProps = {
pageTables: { table: PDFTable; index: number }[]
disabled: boolean
onToggleIncluded: (tableIndex: number, included: boolean) => void
onBboxCommit: (tableIndex: number, bbox: [number, number, number, number]) => void
}
const PageView = ({ pageTables, disabled, onToggleIncluded, onBboxCommit }: PageViewProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const pageImage = pageTables[0]?.table.page_image
const pageWidth = pageTables[0]?.table.page_width ?? 1
const pageHeight = pageTables[0]?.table.page_height ?? 1
if (!pageImage) {
return (
<Paragraph className="text-p-sm text-ink-gray-5">
{_('No page image is available for this page.')}
</Paragraph>
)
}
return (
<div className="flex flex-col gap-3">
{!disabled && (
<Paragraph className="text-xs text-ink-gray-5">
{_('Drag a box to move it, or drag a corner to resize. The table is re-read from the new region automatically.')}
</Paragraph>
)}
<div ref={containerRef} className="relative w-full overflow-auto rounded border border-outline-gray-2 bg-surface-gray-1">
<img src={pageImage} alt={_('Page preview')} className="w-full" />
{pageTables.map(({ table, index }, position) => {
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
return (
<BBoxOverlay
key={index}
bbox={table.bbox}
pageWidth={pageWidth}
pageHeight={pageHeight}
color={color}
label={_('Table {0}', [(position + 1).toString()])}
included={table.included}
disabled={disabled}
containerRef={containerRef}
onCommit={(bbox) => onBboxCommit(index, bbox)}
/>
)
})}
</div>
<div className="flex flex-col gap-1.5">
{pageTables.map(({ table, index }, position) => {
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
return (
<div key={index} className="flex items-center justify-between rounded border border-outline-gray-2 px-2 py-1.5">
<div className="flex items-center gap-2">
<span className={cn('size-3 rounded-sm', color.swatch)} />
<span className="text-xs">{_('Table {0}', [(position + 1).toString()])}</span>
</div>
<IncludeToggle
id={`pdf-tbl-${index}`}
checked={table.included}
disabled={disabled}
onCheckedChange={(c) => onToggleIncluded(index, c)}
/>
</div>
)
})}
</div>
</div>
)
}
const IncludeToggle = ({
id,
checked,
disabled,
onCheckedChange,
}: {
id: string
checked: boolean
disabled: boolean
onCheckedChange: (checked: boolean) => void
}) => (
<div className="flex items-center gap-2">
<Label htmlFor={id} className="text-xs text-ink-gray-6">{_('Include')}</Label>
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
</div>
)
export default PDFTableEditor

View File

@@ -0,0 +1,222 @@
import { useMemo } from 'react'
import {
ArrowDownRightIcon,
ArrowUpDownIcon,
ArrowUpRightIcon,
BanknoteIcon,
CalendarIcon,
DollarSignIcon,
FileTextIcon,
ListIcon,
ReceiptIcon,
} from 'lucide-react'
import _ from '@/lib/translate'
import { cn } from '@/lib/utils'
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { COLUMN_MAPS_TO_OPTIONS, ColumnMapsTo } from './import_utils'
const AMOUNT_COLUMNS: ColumnMapsTo[] = ['Amount', 'Withdrawal', 'Deposit', 'Balance']
const DATE_LIKE = /\d{1,4}[/\-.\s]\d{1,2}[/\-.\s]\d{1,4}|\d{1,2}[\s-][a-z]{3}/i
type Props = {
rows: string[][]
/** Column index -> mapped field */
columnMapping: Record<number, ColumnMapsTo>
headerIndex: number | null
editable?: boolean
disabled?: boolean
onChangeMapping?: (columnIndex: number, mapsTo: ColumnMapsTo) => void
/** Set the header row (or null to mark the table as having no header). */
onSetHeader?: (rowIndex: number | null) => void
}
/**
* A preview of extracted rows with CSV-style colour coding: the header row is highlighted,
* detected transaction rows are green, and mapped columns are emphasised. When `editable`, a
* compact row of column -> field dropdowns sits at the top, and row numbers can be clicked to
* set/clear the header row.
*/
const RawTableGrid = ({ rows, columnMapping, headerIndex, editable, disabled, onChangeMapping, onSetHeader }: Props) => {
// Tabular (XLSX) cells can be numbers/dates, not strings - coerce so .trim()/render are safe.
const stringRows = useMemo(
() => rows.map((row) => row.map((cell) => (cell == null ? '' : String(cell)))),
[rows],
)
const numColumns = useMemo(() => stringRows.reduce((max, row) => Math.max(max, row.length), 0), [stringRows])
const validColumns = useMemo(
() => Object.entries(columnMapping).filter(([, m]) => m && m !== 'Do not import').map(([i]) => Number(i)),
[columnMapping],
)
const dateColumn = useMemo(() => Object.entries(columnMapping).find(([, m]) => m === 'Date')?.[0], [columnMapping])
const amountColumns = useMemo(
() => Object.entries(columnMapping).filter(([, m]) => ['Amount', 'Withdrawal', 'Deposit'].includes(m)).map(([i]) => Number(i)),
[columnMapping],
)
// Approximate the backend's transaction-row detection so the highlighting tracks edits live.
const transactionRows = useMemo(() => {
const set = new Set<number>()
if (dateColumn === undefined) return set
const dateIdx = Number(dateColumn)
stringRows.forEach((row, index) => {
if (index === headerIndex) return
const dateCell = (row[dateIdx] ?? '').trim()
if (!dateCell || !DATE_LIKE.test(dateCell)) return
if (amountColumns.some((c) => (row[c] ?? '').trim() !== '')) set.add(index)
})
return set
}, [stringRows, headerIndex, dateColumn, amountColumns])
return (
<Table containerClassName="rounded-none">
<TableBody>
{editable && (
<TableRow className="border-b border-outline-gray-2 bg-surface-white hover:bg-surface-white">
<TableHead className="w-8 p-1" />
{Array.from({ length: numColumns }).map((_unused, columnIndex) => (
<TableHead key={columnIndex} className="p-1 align-top">
<Select
disabled={disabled}
value={columnMapping[columnIndex] ?? 'Do not import'}
onValueChange={(value) => onChangeMapping?.(columnIndex, value as ColumnMapsTo)}
>
<SelectTrigger variant="outline" inputSize="sm" className="h-7 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_MAPS_TO_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
<span className="flex items-center gap-1.5">
<ColumnHeaderIcon columnType={option} />
{_(option)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</TableHead>
))}
</TableRow>
)}
{stringRows.map((row, index) => {
const isHeaderRow = index === headerIndex
const isTransactionRow = transactionRows.has(index)
return (
<TableRow
key={index}
className={cn({
'bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700': isTransactionRow,
'bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400': isHeaderRow,
'text-ink-gray-5/70': !isTransactionRow && !isHeaderRow,
})}
>
{editable && onSetHeader ? (
<TableCell className="h-px w-8 p-0 text-center">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
disabled={disabled}
onClick={() => onSetHeader(isHeaderRow ? null : index)}
className={cn(
'flex h-full w-full items-center justify-center px-1 text-ink-gray-6 hover:bg-surface-gray-3',
isHeaderRow && 'font-semibold text-ink-gray-8',
)}
>
{index + 1}
</button>
</TooltipTrigger>
<TooltipContent>
{isHeaderRow
? _('This is the header row. Click to mark the table as having no header.')
: _('Click to set this as the header row.')}
</TooltipContent>
</Tooltip>
</TableCell>
) : (
<TableCell className="w-8 px-1 py-0.5 text-center text-ink-gray-6">{index + 1}</TableCell>
)}
{Array.from({ length: numColumns }).map((_unused, cellIndex) => {
const columnType = columnMapping[cellIndex]
const isValidColumn = validColumns.includes(cellIndex)
const isAmountColumn = AMOUNT_COLUMNS.includes(columnType)
const cellText = row[cellIndex] ?? ''
// Read-only header row: icon + label.
if (isHeaderRow) {
return (
<TableCell key={cellIndex} className="max-w-[200px] overflow-hidden text-ellipsis py-1">
<div className="flex items-center gap-1 px-1 text-xs font-medium text-ink-gray-8">
{columnType && (
<Tooltip>
<TooltipTrigger>
<ColumnHeaderIcon columnType={columnType} />
</TooltipTrigger>
<TooltipContent>{_(columnType)}</TooltipContent>
</Tooltip>
)}
{cellText}
</div>
</TableCell>
)
}
return (
<TableCell
key={cellIndex}
className={cn('max-w-[200px] overflow-hidden text-ellipsis py-0.5', {
'bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400': isValidColumn && isTransactionRow,
'text-ink-gray-5': !isValidColumn && isTransactionRow,
})}
>
<div
className={cn('min-h-5 flex items-center px-1 text-xs', {
'justify-end': isAmountColumn && isValidColumn && isTransactionRow,
})}
title={cellText}
>
{cellText}
</div>
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
)
}
const ColumnHeaderIcon = ({ columnType }: { columnType?: ColumnMapsTo }) => {
switch (columnType) {
case 'Amount':
return <DollarSignIcon className="size-4" />
case 'Withdrawal':
return <ArrowUpRightIcon className="size-4 text-ink-red-3" />
case 'Deposit':
return <ArrowDownRightIcon className="size-4 text-ink-green-3" />
case 'Balance':
return <BanknoteIcon className="size-4" />
case 'Date':
return <CalendarIcon className="size-4" />
case 'Description':
return <FileTextIcon className="size-4" />
case 'Reference':
return <ReceiptIcon className="size-4" />
case 'Transaction Type':
return <ListIcon className="size-4" />
case 'Debit/Credit':
return <ArrowUpDownIcon className="size-4" />
default:
return null
}
}
export default RawTableGrid

View File

@@ -1,6 +1,97 @@
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
import { useFrappeGetCall } from "frappe-react-sdk"
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
export type ColumnMapsTo =
| "Do not import"
| "Date"
| "Withdrawal"
| "Deposit"
| "Amount"
| "Description"
| "Reference"
| "Transaction Type"
| "Debit/Credit"
| "Balance"
| "Included Fee"
| "Excluded Fee"
| "Party Name/Account Holder"
| "Party Account No."
| "Party IBAN"
export type ColumnMappingEntry = {
index: number
maps_to: ColumnMapsTo | string
header_text?: string
variable?: string
}
/** Apply a column mapping change, clearing the same mapping from any other column. */
export function applyColumnMappingChange<T extends ColumnMappingEntry>(
columns: T[],
columnIndex: number,
mapsTo: ColumnMapsTo,
): T[] {
const previous = columns.find((c) => c.index === columnIndex)
const cleared =
mapsTo === "Do not import"
? columns
: columns.map((c) =>
c.index !== columnIndex && c.maps_to === mapsTo
? { ...c, maps_to: "Do not import" as ColumnMapsTo }
: c,
)
return [
...cleared.filter((c) => c.index !== columnIndex),
{
index: columnIndex,
maps_to: mapsTo,
header_text: previous?.header_text ?? "",
variable: previous?.variable ?? `column_${columnIndex}`,
} as T,
].sort((a, b) => a.index - b.index)
}
export const COLUMN_MAPS_TO_OPTIONS: ColumnMapsTo[] = [
"Do not import",
"Date",
"Description",
"Reference",
"Withdrawal",
"Deposit",
"Amount",
"Balance",
"Debit/Credit",
"Transaction Type",
"Included Fee",
"Excluded Fee",
"Party Name/Account Holder",
"Party Account No.",
"Party IBAN",
]
export interface PDFTableColumn {
index: number
header_text: string
variable?: string
maps_to: ColumnMapsTo
}
export interface PDFTable {
page: number
table_index: number
bbox: [number, number, number, number]
page_width: number
page_height: number
page_image: string | null
render_scale: number | null
rows: string[][]
header_index: number | null
column_mapping: PDFTableColumn[]
date_format?: string
amount_format?: string
included: boolean
}
export interface GetStatementDetailsResponse {
doc: BankStatementImportLog,
@@ -30,6 +121,7 @@ export interface GetStatementDetailsResponse {
date_format: string,
raw_data: Array<Array<string>>,
currency: string,
pdf_tables?: PDFTable[],
}
export const useGetStatementDetails = (id: string) => {
@@ -39,4 +131,24 @@ export const useGetStatementDetails = (id: string) => {
revalidateOnFocus: false
})
}
export const useUpdatePDFTables = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_pdf_tables")
}
export const useReextractPDFTable = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.reextract_pdf_table")
}
export const useSetPDFTableHeader = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_pdf_table_header")
}
export const useUpdateColumnMapping = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_column_mapping")
}
export const useSetHeaderIndex = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_header_index")
}

View File

@@ -231,7 +231,7 @@ export const FileTypeIcon = ({
const getTextColor = () => {
switch (fileType.toLowerCase()) {
case 'pdf':
return 'text-red-700'
return 'text-ink-red-3'
case 'doc':
case 'docx':
return 'text-[#1A5CBD]'

View File

@@ -33,6 +33,16 @@ export const getErrorMessages = (error?: FrappeError | null): ParsedErrorMessage
}
})
// @ts-expect-error - some errors have _error_message
if (error?._error_message) {
eMessages.push({
// @ts-expect-error - some errors have _error_message
message: error?._error_message,
title: "Error",
indicator: "red"
})
}
if (eMessages.length === 0) {
// Get the message from the exception by removing the exc_type
const indexOfFirstColon = error?.exception?.indexOf(':')

View File

@@ -7,6 +7,7 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, Di
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import ErrorBanner from "@/components/ui/error-banner"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { H3, Paragraph } from "@/components/ui/typography"
@@ -16,7 +17,7 @@ import { flt, formatCurrency } from "@/lib/numbers"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList } from "frappe-react-sdk"
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList, useFrappeUpdateDoc } from "frappe-react-sdk"
import { useAtom, useAtomValue } from "jotai"
import { ListIcon, Loader2Icon } from "lucide-react"
import { useState } from "react"
@@ -30,11 +31,15 @@ const BankStatementImporter = () => {
const [selectedBankAccount] = useAtom(selectedBankAccountAtom)
const [files, setFiles] = useState<File[]>([])
const [password, setPassword] = useState("")
const { upload, error, loading } = useFrappeFileUpload()
const navigate = useNavigate()
const { createDoc, loading: createLoading, error: createError } = useFrappeCreateDoc<BankStatementImportLog>()
const { updateDoc, error: updateError } = useFrappeUpdateDoc()
const isPdf = files[0]?.name?.toLowerCase().endsWith(".pdf") ?? false
const onUpload = () => {
@@ -44,12 +49,18 @@ const BankStatementImporter = () => {
const id = `new-bank-statement-import-log-${Date.now()}`
upload(files[0], {
// For protected PDFs, persist the password on the Bank Account so it is reused for
// every statement of this account (and is available before the import doc is created).
const ensurePassword = isPdf && password
? updateDoc("Bank Account", selectedBankAccount.name, { statement_password: password })
: Promise.resolve()
ensurePassword.then(() => upload(files[0], {
isPrivate: true,
doctype: "Bank Statement Import Log",
docname: id,
fieldname: 'file'
}).then((file) => {
})).then((file) => {
return createDoc("Bank Statement Import Log",
// @ts-expect-error - not filling everything else
{
@@ -67,6 +78,7 @@ const BankStatementImporter = () => {
<div className="w-[52%]">
{error && <ErrorBanner error={error} />}
{createError && <ErrorBanner error={createError} />}
{updateError && <ErrorBanner error={updateError} />}
<div className="py-2 flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label>{_("Company")}<span className="text-ink-red-3">*</span></Label>
@@ -89,7 +101,7 @@ const BankStatementImporter = () => {
data-slot="form-description"
className={cn("text-ink-gray-5 text-xs")}
>
{_("Upload your bank statement file to start the import process. We support CSV, and XLSX files.")}
{_("Upload your bank statement file to start the import process. We support CSV, XLSX and PDF files.")}
</p>
</div>
<div>
@@ -105,10 +117,27 @@ const BankStatementImporter = () => {
'text/csv': ['.csv'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
'application/pdf': ['.pdf'],
// 'application/xml': ['.xml'],
}}
multiple={false}
/>
{isPdf && <div className="flex flex-col gap-2">
<Label htmlFor="pdf-password">{_("PDF Password")}</Label>
<Input
id="pdf-password"
type="password"
autoComplete="off"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={_("Only if the PDF is password protected")}
className="max-w-sm"
/>
<p data-slot="form-description" className={cn("text-ink-gray-5 text-p-sm")}>
{_("Leave blank to use the password already saved for this bank account (if any). It is stored encrypted and reused for future statements.")}
</p>
</div>}
</div>}
<div className="flex justify-end px-4">
<Button
@@ -137,9 +166,10 @@ const StatementInstructions = () => {
<DialogContent className="min-w-7xl">
<DialogHeader>
<DialogTitle>{_("Statement Import Instructions")}</DialogTitle>
<DialogDescription>{_("We support uploading CSV, XLSX and XLS files. Please make sure the file contains the correct columns.")}</DialogDescription>
<DialogDescription>{_("We support uploading CSV, XLSX, XLS and PDF files. Please make sure the file contains the correct columns.")}</DialogDescription>
</DialogHeader>
<Paragraph className="text-sm">{_("The file should contain the following columns with a distinct header row. You can upload most bank statements as is without changing the columns.")}</Paragraph>
<Paragraph className="text-sm text-ink-gray-6">{_("For PDF statements, we auto-detect the tables on each page. You can then confirm each detected table, map its columns, and exclude anything that is not transactions (e.g. ads or summaries). Password-protected PDFs are supported - the password is saved on the bank account and reused.")}</Paragraph>
<Table>
<TableHeader>
<TableRow>
@@ -231,7 +261,13 @@ const StatementImportLog = () => {
<TableRow key={item.name} onClick={() => onViewDetails(item.name)} className="cursor-pointer hover:bg-surface-gray-2">
<TableCell>{formatDate(item.creation, 'Do MMM YYYY')}</TableCell>
<TableCell><Badge theme={item.status === "Completed" ? "green" : "gray"}>{item.status}</Badge></TableCell>
<TableCell>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</TableCell>
<TableCell>
{item.start_date && item.end_date ? (
<span>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</span>
) : (
<span>-</span>
)}
</TableCell>
<TableCell className="text-end">{item.number_of_transactions}</TableCell>
<TableCell className="text-end font-numeric">{formatCurrency(flt(item.closing_balance, 2))}</TableCell>
<TableCell><a

View File

@@ -9,12 +9,13 @@ import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { Link, useParams } from 'react-router'
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
const PDFImport = lazy(() => import('@/components/features/BankStatementImporter/PDF/PDFImport'))
const ViewBankStatementImportLog = () => {
const { id } = useParams<{ id: string }>()
const { data, isLoading, error } = useGetStatementDetails(id ?? "")
const { data, isLoading, error, mutate } = useGetStatementDetails(id ?? "")
useFrappeDocumentEventListener("Bank Statement Import Log", id ?? "", () => {
})
@@ -42,7 +43,13 @@ const ViewBankStatementImportLog = () => {
<ErrorBanner error={error} />
</div>
}
return <CSVImport data={data} />
const isPdf = data.message.doc.file?.toLowerCase().endsWith('.pdf')
if (isPdf) {
return <PDFImport data={data} mutate={mutate} />
}
return <CSVImport data={data} mutate={mutate} />
}
export default ViewBankStatementImportLog

View File

@@ -38,6 +38,8 @@ export interface BankAccount{
branch_code?: string
/** Bank Account No : Data */
bank_account_no?: string
/** Statement PDF Password : Password - Password used to open password-protected PDF statements for this account. Stored encrypted. */
statement_password?: string
/** Is Credit Card : Check */
is_credit_card?: 0 | 1
/** Integration ID : Data */

View File

@@ -47,4 +47,6 @@ export interface BankStatementImportLog {
detected_transaction_ending_index?: number
/** Column Mapping : Table - Bank Statement Import Log Column Map */
column_mapping?: BankStatementImportLogColumnMap[]
/** PDF Tables : JSON - Per-table extraction data for PDF statements */
pdf_tables?: string
}

View File

@@ -358,10 +358,10 @@
dependencies:
"@tybys/wasm-util" "^0.10.1"
"@oxc-project/types@=0.128.0":
version "0.128.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.128.0.tgz#efc7524f948ff9e8ab1404ecad1823849c6fe149"
integrity sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==
"@oxc-project/types@=0.133.0":
version "0.133.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8"
integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==
"@radix-ui/number@1.1.1":
version "1.1.1"
@@ -1042,95 +1042,95 @@
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
"@rolldown/binding-android-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz#3af8b2242086125934a85c1915b76e0a6a2054c1"
integrity sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==
"@rolldown/binding-android-arm64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz#54ce8f8382213f4a314a0c2f7ba83f81ffeae592"
integrity sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==
"@rolldown/binding-darwin-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz#ae0b4467d24ecd6c6589f03d4d4699616ee9649c"
integrity sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==
"@rolldown/binding-darwin-arm64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz#388fca1566c14c00c4b446fc3928630e7f0d95fc"
integrity sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==
"@rolldown/binding-darwin-x64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz#23cf24b0a7b96c8990bbdd8a91e7fd3ba82b00e7"
integrity sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==
"@rolldown/binding-darwin-x64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz#53f57de1f599ecf1db13823cfc88c18fb80954ad"
integrity sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==
"@rolldown/binding-freebsd-x64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz#a047a770f94dc451c062b729e5d1cf82e5c6f9c4"
integrity sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==
"@rolldown/binding-freebsd-x64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz#6f3fdda1b7aeaac9d268a526804b4fb96e4e35f1"
integrity sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz#c0b7f346cbf50301cea669a4632bc63aabe6a72c"
integrity sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==
"@rolldown/binding-linux-arm-gnueabihf@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz#d87a454bf585cc9676849377e91d6e375297326f"
integrity sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz#af56373c7996ebe6379207cd699c9f7f705e235d"
integrity sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==
"@rolldown/binding-linux-arm64-gnu@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz#419fd6bf612cf348f10528cbcd94ebab9607d8d1"
integrity sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz#a8f5acd21fcffc8991aa84710e3ae603c4240ea4"
integrity sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==
"@rolldown/binding-linux-arm64-musl@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz#fcc6918696bb76844877e1e4930a18fd0d374069"
integrity sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz#1d4a89e040ff82141fc46e717cfab80b05f7c13f"
integrity sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==
"@rolldown/binding-linux-ppc64-gnu@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz#32aecb7c8dae5d4f2a8cde57a058ec86991542f8"
integrity sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz#97c21feeb2ed87d07820f0b2dcc5dd663e7a7f3b"
integrity sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==
"@rolldown/binding-linux-s390x-gnu@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz#bed9346ea81e6bb8b93cf11f5d88b77db890b763"
integrity sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz#06310d40fe139ccc3c433b361120d337c66ebec2"
integrity sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==
"@rolldown/binding-linux-x64-gnu@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz#64c2d26f75dffd9b5a1f97557a00ae77250c8cb7"
integrity sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==
"@rolldown/binding-linux-x64-musl@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz#6a711258841f42609b238050cfcd5db13ac136d0"
integrity sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==
"@rolldown/binding-linux-x64-musl@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz#5a45132e8a47659eeaaf3b540c2954a97c860ff3"
integrity sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==
"@rolldown/binding-openharmony-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz#15cb644beeafdbec930d79ed45c2a7c2573eac70"
integrity sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==
"@rolldown/binding-openharmony-arm64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz#290513068c55e849dc8457a32afee1d7b0acb309"
integrity sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==
"@rolldown/binding-wasm32-wasi@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz#ca3a56d11dfd533d743711141b3bb4c1ec10110e"
integrity sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==
"@rolldown/binding-wasm32-wasi@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz#3d9972dbf1a953d3c7afaa4a0f20ef2b2e39f31b"
integrity sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==
dependencies:
"@emnapi/core" "1.10.0"
"@emnapi/runtime" "1.10.0"
"@napi-rs/wasm-runtime" "^1.1.4"
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz#8c2117d68331d7de59d24631146d538fc203d27c"
integrity sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==
"@rolldown/binding-win32-arm64-msvc@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz#a004ab607a16d6f03bcb555728ff888af75773ad"
integrity sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz#bb5c28df3095046778cc1b020ef52fc5ee7b7e70"
integrity sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==
"@rolldown/pluginutils@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz#51cf2589596a179ebe8cbf313f1358c7b51a2fdc"
integrity sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==
"@rolldown/binding-win32-x64-msvc@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz#e2a25b34691a1cc8a1209d7de709063026dd0cdb"
integrity sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==
"@rolldown/pluginutils@1.0.0-rc.7":
version "1.0.0-rc.7"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022"
integrity sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==
"@rolldown/pluginutils@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be"
integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==
"@socket.io/component-emitter@~3.1.0":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
@@ -3031,10 +3031,10 @@ ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
nanoid@^3.3.12:
version "3.3.12"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05"
integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==
natural-compare@^1.4.0:
version "1.4.0"
@@ -3119,22 +3119,17 @@ picocolors@^1.1.1:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
picomatch@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
postcss@^8.5.14:
version "8.5.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
postcss@^8.5.15:
version "8.5.15"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c"
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
dependencies:
nanoid "^3.3.11"
nanoid "^3.3.12"
picocolors "^1.1.1"
source-map-js "^1.2.1"
@@ -3394,29 +3389,29 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
rolldown@1.0.0-rc.18:
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.18.tgz#c597f89a4ce12e6fc918fa91e4f892b340aa92f0"
integrity sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==
rolldown@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.3.tgz#db88a3008fb0e28230a00423727ce75ba32121ac"
integrity sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==
dependencies:
"@oxc-project/types" "=0.128.0"
"@rolldown/pluginutils" "1.0.0-rc.18"
"@oxc-project/types" "=0.133.0"
"@rolldown/pluginutils" "^1.0.0"
optionalDependencies:
"@rolldown/binding-android-arm64" "1.0.0-rc.18"
"@rolldown/binding-darwin-arm64" "1.0.0-rc.18"
"@rolldown/binding-darwin-x64" "1.0.0-rc.18"
"@rolldown/binding-freebsd-x64" "1.0.0-rc.18"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.18"
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.18"
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.18"
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.18"
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.18"
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.18"
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.18"
"@rolldown/binding-android-arm64" "1.0.3"
"@rolldown/binding-darwin-arm64" "1.0.3"
"@rolldown/binding-darwin-x64" "1.0.3"
"@rolldown/binding-freebsd-x64" "1.0.3"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.3"
"@rolldown/binding-linux-arm64-gnu" "1.0.3"
"@rolldown/binding-linux-arm64-musl" "1.0.3"
"@rolldown/binding-linux-ppc64-gnu" "1.0.3"
"@rolldown/binding-linux-s390x-gnu" "1.0.3"
"@rolldown/binding-linux-x64-gnu" "1.0.3"
"@rolldown/binding-linux-x64-musl" "1.0.3"
"@rolldown/binding-openharmony-arm64" "1.0.3"
"@rolldown/binding-wasm32-wasi" "1.0.3"
"@rolldown/binding-win32-arm64-msvc" "1.0.3"
"@rolldown/binding-win32-x64-msvc" "1.0.3"
scheduler@^0.27.0:
version "0.27.0"
@@ -3540,18 +3535,10 @@ tapable@^2.3.3:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
tinyglobby@^0.2.15:
version "0.2.15"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.3"
tinyglobby@^0.2.16:
version "0.2.16"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
tinyglobby@^0.2.15, tinyglobby@^0.2.17:
version "0.2.17"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.4"
@@ -3725,16 +3712,16 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
vite@^8.0.11:
version "8.0.11"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.11.tgz#d128fe82a0dd24da5127d20560735f1cd7ade0a6"
integrity sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==
vite@^8.0.16:
version "8.0.16"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.16.tgz#ae073866c06563d6634a90169a496e11bd84f1a6"
integrity sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==
dependencies:
lightningcss "^1.32.0"
picomatch "^4.0.4"
postcss "^8.5.14"
rolldown "1.0.0-rc.18"
tinyglobby "^0.2.16"
postcss "^8.5.15"
rolldown "1.0.3"
tinyglobby "^0.2.17"
optionalDependencies:
fsevents "~2.3.3"

View File

@@ -3,8 +3,6 @@ import inspect
from typing import TypeVar
import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "17.0.0-dev"
@@ -155,6 +153,8 @@ def allow_regional(fn):
def check_app_permission():
from frappe.utils.user import is_website_user
if frappe.session.user == "Administrator":
return True
@@ -175,6 +175,8 @@ def normalize_ctx_input(T: type) -> callable:
- Casting the result to the specified type T
"""
from frappe.model.document import Document
def decorator(func: callable):
# conserve annotations for frappe.utils.typing_validations
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))

View File

@@ -1,6 +1,7 @@
import frappe
from frappe import _
from frappe.email import sendmail_to_system_managers
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import (
add_days,
add_months,
@@ -53,20 +54,24 @@ def validate_service_stop_date(doc):
def build_conditions(process_type, account, company):
conditions = ""
deferred_account = (
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
)
if process_type == "Income":
item = frappe.qb.DocType("Sales Invoice Item")
parent = frappe.qb.DocType("Sales Invoice")
deferred_account = item.deferred_revenue_account
else:
item = frappe.qb.DocType("Purchase Invoice Item")
parent = frappe.qb.DocType("Purchase Invoice")
deferred_account = item.deferred_expense_account
if account:
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
return deferred_account == account
elif company:
conditions += f"AND p.company = {frappe.db.escape(company)}"
return parent.company == company
return conditions
return None
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
if not start_date:
@@ -75,17 +80,25 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
end_date = add_days(today(), -1)
# check for the purchase invoice for which GL entries has to be done
invoices = frappe.db.sql_list(
f"""
select distinct item.parent
from `tabPurchase Invoice Item` item, `tabPurchase Invoice` p
where item.service_start_date<=%s and item.service_end_date>=%s
and item.enable_deferred_expense = 1 and item.parent=p.name
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
{conditions}
""",
(end_date, start_date),
) # nosec
item = frappe.qb.DocType("Purchase Invoice Item")
parent = frappe.qb.DocType("Purchase Invoice")
query = (
frappe.qb.from_(item)
.inner_join(parent)
.on(item.parent == parent.name)
.select(item.parent)
.distinct()
.where(
(item.service_start_date <= end_date)
& (item.service_end_date >= start_date)
& (item.enable_deferred_expense == 1)
& (item.docstatus == 1)
& (IfNull(item.amount, 0) > 0)
)
)
if conditions is not None:
query = query.where(conditions)
invoices = query.run(pluck=True)
# For each invoice, book deferred expense
for invoice in invoices:
@@ -96,7 +109,7 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
send_mail(deferred_process)
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=None):
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
if not start_date:
@@ -105,17 +118,25 @@ def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_da
end_date = add_days(today(), -1)
# check for the sales invoice for which GL entries has to be done
invoices = frappe.db.sql_list(
f"""
select distinct item.parent
from `tabSales Invoice Item` item, `tabSales Invoice` p
where item.service_start_date<=%s and item.service_end_date>=%s
and item.enable_deferred_revenue = 1 and item.parent=p.name
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
{conditions}
""",
(end_date, start_date),
) # nosec
item = frappe.qb.DocType("Sales Invoice Item")
parent = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(item)
.inner_join(parent)
.on(item.parent == parent.name)
.select(item.parent)
.distinct()
.where(
(item.service_start_date <= end_date)
& (item.service_end_date >= start_date)
& (item.enable_deferred_revenue == 1)
& (item.docstatus == 1)
& (IfNull(item.amount, 0) > 0)
)
)
if conditions is not None:
query = query.where(conditions)
invoices = query.run(pluck=True)
for invoice in invoices:
doc = frappe.get_doc("Sales Invoice", invoice)
@@ -136,26 +157,39 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
)
if not prev_posting_date:
prev_gl_entry = frappe.db.sql(
"""
select name, posting_date from `tabGL Entry` where company=%s and account=%s and
voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
and is_cancelled = 0
order by posting_date desc limit 1
""",
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
prev_gl_entry = frappe.get_all(
"GL Entry",
filters={
"company": doc.company,
"account": item.get(deferred_account),
"voucher_type": doc.doctype,
"voucher_no": doc.name,
"voucher_detail_no": item.name,
"is_cancelled": 0,
},
fields=["name", "posting_date"],
order_by="posting_date desc",
limit=1,
)
prev_gl_via_je = frappe.db.sql(
"""
SELECT p.name, p.posting_date FROM `tabJournal Entry` p, `tabJournal Entry Account` c
WHERE p.name = c.parent and p.company=%s and c.account=%s
and c.reference_type=%s and c.reference_name=%s
and c.reference_detail_no=%s and c.docstatus < 2 order by posting_date desc limit 1
""",
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
prev_gl_via_je = (
frappe.qb.from_(je)
.inner_join(jea)
.on(je.name == jea.parent)
.select(je.name, je.posting_date)
.where(
(je.company == doc.company)
& (jea.account == item.get(deferred_account))
& (jea.reference_type == doc.doctype)
& (jea.reference_name == doc.name)
& (jea.reference_detail_no == item.name)
& (jea.docstatus < 2)
)
.orderby(je.posting_date, order=frappe.qb.desc)
.limit(1)
.run(as_dict=True)
)
if prev_gl_via_je:
@@ -277,26 +311,47 @@ def get_already_booked_amount(doc, item):
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
deferred_account = "deferred_expense_account"
gl_entries_details = frappe.db.sql(
"""
select sum({}) as total_credit, sum({}) as total_credit_in_account_currency, voucher_detail_no
from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
and is_cancelled = 0
group by voucher_detail_no
""".format(total_credit_debit, total_credit_debit_currency),
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
gle = frappe.qb.DocType("GL Entry")
gl_entries_details = (
frappe.qb.from_(gle)
.select(
Sum(gle[total_credit_debit]).as_("total_credit"),
Sum(gle[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
gle.voucher_detail_no,
)
.where(
(gle.company == doc.company)
& (gle.account == item.get(deferred_account))
& (gle.voucher_type == doc.doctype)
& (gle.voucher_no == doc.name)
& (gle.voucher_detail_no == item.name)
& (gle.is_cancelled == 0)
)
.groupby(gle.voucher_detail_no)
.run(as_dict=True)
)
journal_entry_details = frappe.db.sql(
"""
SELECT sum(c.{}) as total_credit, sum(c.{}) as total_credit_in_account_currency, reference_detail_no
FROM `tabJournal Entry` p , `tabJournal Entry Account` c WHERE p.name = c.parent and
p.company = %s and c.account=%s and c.reference_type=%s and c.reference_name=%s and c.reference_detail_no=%s
and p.docstatus < 2 group by reference_detail_no
""".format(total_credit_debit, total_credit_debit_currency),
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
journal_entry_details = (
frappe.qb.from_(je)
.inner_join(jea)
.on(je.name == jea.parent)
.select(
Sum(jea[total_credit_debit]).as_("total_credit"),
Sum(jea[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
jea.reference_detail_no,
)
.where(
(je.company == doc.company)
& (jea.account == item.get(deferred_account))
& (jea.reference_type == doc.doctype)
& (jea.reference_name == doc.name)
& (jea.reference_detail_no == item.name)
& (je.docstatus < 2)
)
.groupby(jea.reference_detail_no)
.run(as_dict=True)
)
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0

View File

@@ -234,7 +234,7 @@ class Account(NestedSet):
if not frappe.db.get_value(
"Account", {"account_name": self.account_name, "company": ancestors[0]}, "name"
):
frappe.throw(_("Please add the account to root level Company - {}").format(ancestors[0]))
frappe.throw(_("Please add the account to root level Company - {0}").format(ancestors[0]))
elif self.parent_account:
descendants = get_descendants_of("Company", self.company)
if not descendants:
@@ -592,10 +592,12 @@ def update_account_number(
@frappe.whitelist()
def merge_account(old: str, new: str):
_ensure_idle_system()
# Validate properties before merging
new_account = frappe.get_cached_doc("Account", new)
old_account = frappe.get_cached_doc("Account", old)
new_account.check_permission("write")
old_account.check_permission("write")
if not new_account:
throw(_("Account {0} does not exist").format(new))
@@ -669,7 +671,7 @@ def _ensure_idle_system():
if last_gl_update > add_to_date(None, minutes=-5):
frappe.throw(
_(
"Last GL Entry update was done {}. This operation is not allowed while system is actively being used. Please wait for 5 minutes before retrying."
"Last GL Entry update was done {0}. This operation is not allowed while system is actively being used. Please wait for 5 minutes before retrying."
).format(pretty_date(last_gl_update)),
title=_("System In Use"),
)

View File

@@ -236,9 +236,9 @@ frappe.treeview_settings["Account"] = {
function () {
let root_company = treeview.page.fields_dict.root_company.get_value();
if (root_company) {
frappe.throw(__("Please add the account to root level Company - {0}"), [
root_company,
]);
frappe.throw(
__("Please add the account to root level Company - {0}", [root_company])
);
} else {
treeview.new_node();
}

View File

@@ -137,7 +137,7 @@ def get_charts_for_country(country: str, with_standard: bool = False):
def _get_chart_name(content):
if content:
content = json.loads(content)
content = frappe.parse_json(content)
if (
content and content.get("disabled", "No") == "No"
) or frappe.local.flags.allow_unverified_charts:

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

@@ -570,6 +570,17 @@
"account_number": "5000",
"is_group": 1,
"root_type": "Expense",
"Cost of Goods Sold": {
"account_number": "5001",
"is_group": 1,
"root_type": "Expense",
"Cost of Goods Sold": {
"account_number": "5010",
"is_group": 0,
"root_type": "Expense",
"account_type": "Cost of Goods Sold"
}
},
"Operating Expenses": {
"account_number": "5100",
"is_group": 1,

View File

@@ -198,21 +198,9 @@ def add_dimension_to_budget_doctype(df, doc):
def delete_accounting_dimension(doc):
doclist = get_doctypes_with_dimensions()
frappe.db.sql(
"""
DELETE FROM `tabCustom Field`
WHERE fieldname = {}
AND dt IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
tuple([doc.fieldname, *doclist]),
)
frappe.db.delete("Custom Field", filters={"fieldname": doc.fieldname, "dt": ["in", doclist]})
frappe.db.sql(
"""
DELETE FROM `tabProperty Setter`
WHERE field_name = {}
AND doc_type IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
tuple([doc.fieldname, *doclist]),
)
frappe.db.delete("Property Setter", filters={"field_name": doc.fieldname, "doc_type": ["in", doclist]})
budget_against_property = frappe.get_doc("Property Setter", "Budget-budget_against-options")
value_list = budget_against_property.value.split("\n")[3:]
@@ -236,7 +224,7 @@ def disable_dimension(doc: str):
def toggle_disabling(doc):
doc = json.loads(doc)
doc = frappe.parse_json(doc)
if doc.get("disabled"):
df = {"read_only": 1}
@@ -273,13 +261,27 @@ def get_accounting_dimensions(as_list=True):
def get_checks_for_pl_and_bs_accounts():
return frappe.db.sql(
"""SELECT p.label, p.disabled, p.fieldname, c.default_dimension, c.company, c.mandatory_for_pl, c.mandatory_for_bs
FROM `tabAccounting Dimension`p ,`tabAccounting Dimension Detail` c
WHERE p.name = c.parent AND p.disabled = 0""",
as_dict=1,
AccountingDimension = frappe.qb.DocType("Accounting Dimension")
AccountingDimensionDetail = frappe.qb.DocType("Accounting Dimension Detail")
query = (
frappe.qb.from_(AccountingDimension)
.join(AccountingDimensionDetail)
.on(AccountingDimension.name == AccountingDimensionDetail.parent)
.select(
AccountingDimension.label,
AccountingDimension.disabled,
AccountingDimension.fieldname,
AccountingDimensionDetail.default_dimension,
AccountingDimensionDetail.company,
AccountingDimensionDetail.mandatory_for_pl,
AccountingDimensionDetail.mandatory_for_bs,
)
.where(AccountingDimension.disabled == 0)
)
return query.run(as_dict=1)
def get_dimension_with_children(doctype, dimensions):
if isinstance(dimensions, str):

View File

@@ -43,18 +43,19 @@ class AccountingDimensionFilter(Document):
self.validate_applicable_accounts()
def validate_applicable_accounts(self):
accounts = frappe.db.sql(
"""
SELECT a.applicable_on_account as account
FROM `tabApplicable On Account` a, `tabAccounting Dimension Filter` d
WHERE d.name = a.parent
and d.name != %s
and d.accounting_dimension = %s
""",
(self.name, self.accounting_dimension),
as_dict=1,
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
query = (
frappe.qb.from_(ApplicableOnAccount)
.join(AccountingDimensionFilter)
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
.select(ApplicableOnAccount.applicable_on_account.as_("account"))
.where(AccountingDimensionFilter.name != self.name)
.where(AccountingDimensionFilter.accounting_dimension == self.accounting_dimension)
)
accounts = query.run(as_dict=1)
account_list = [d.account for d in accounts]
for account in self.get("accounts"):
@@ -69,22 +70,28 @@ class AccountingDimensionFilter(Document):
def get_dimension_filter_map():
filters = frappe.db.sql(
"""
SELECT
a.applicable_on_account, d.dimension_value, p.accounting_dimension,
p.allow_or_restrict, p.fieldname, a.is_mandatory
FROM
`tabApplicable On Account` a,
`tabAccounting Dimension Filter` p
LEFT JOIN `tabAllowed Dimension` d ON d.parent = p.name
WHERE
p.name = a.parent
AND p.disabled = 0
""",
as_dict=1,
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
AllowedDimension = frappe.qb.DocType("Allowed Dimension")
query = (
frappe.qb.from_(AccountingDimensionFilter)
.join(ApplicableOnAccount)
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
.left_join(AllowedDimension)
.on(AllowedDimension.parent == AccountingDimensionFilter.name)
.select(
ApplicableOnAccount.applicable_on_account,
AllowedDimension.dimension_value,
AccountingDimensionFilter.accounting_dimension,
AccountingDimensionFilter.allow_or_restrict,
AccountingDimensionFilter.fieldname,
ApplicableOnAccount.is_mandatory,
)
.where(AccountingDimensionFilter.disabled == 0)
)
filters = query.run(as_dict=1)
dimension_filter_map = {}
for f in filters:

View File

@@ -46,23 +46,19 @@ class AccountingPeriod(Document):
self.name = " - ".join([self.period_name, company_abbr])
def validate_overlap(self):
existing_accounting_period = frappe.db.sql(
"""select name from `tabAccounting Period`
where (
(%(start_date)s between start_date and end_date)
or (%(end_date)s between start_date and end_date)
or (start_date between %(start_date)s and %(end_date)s)
or (end_date between %(start_date)s and %(end_date)s)
) and name!=%(name)s and company=%(company)s""",
{
"start_date": self.start_date,
"end_date": self.end_date,
"name": self.name,
"company": self.company,
},
as_dict=True,
AccountingPeriod = frappe.qb.DocType("Accounting Period")
query = (
frappe.qb.from_(AccountingPeriod)
.select(AccountingPeriod.name)
.where(AccountingPeriod.start_date <= self.end_date)
.where(AccountingPeriod.end_date >= self.start_date)
.where(AccountingPeriod.name != self.name)
.where(AccountingPeriod.company == self.company)
)
existing_accounting_period = query.run(as_dict=True)
if len(existing_accounting_period) > 0:
frappe.throw(
_("Accounting Period overlaps with {0}").format(existing_accounting_period[0].get("name")),

View File

@@ -10,6 +10,9 @@ frappe.ui.form.on("Accounts Settings", {
},
};
});
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
},
enable_immutable_ledger: function (frm) {
if (!frm.doc.enable_immutable_ledger) {
@@ -49,3 +52,16 @@ function toggle_tax_settings(frm, field_name) {
frm.set_value(other_field, 0);
}
}
function get_transactions(frm) {
const transactions = [
{ label: __("Journal Entry"), doctype: "Journal Entry" },
{ label: __("Payment Entry"), doctype: "Payment Entry" },
{ label: __("Purchase Invoice"), doctype: "Purchase Invoice" },
{ label: __("Purchase Order"), doctype: "Purchase Order" },
{ label: __("Purchase Receipt"), doctype: "Purchase Receipt" },
{ label: __("Sales Invoice"), doctype: "Sales Invoice" },
];
return transactions;
}

View File

@@ -23,9 +23,9 @@
"confirm_before_resetting_posting_date",
"preview_mode",
"analytics_section",
"enable_discounts_and_margin",
"enable_accounting_dimensions",
"column_break_vtnr",
"enable_discounts_and_margin",
"journals_section",
"merge_similar_account_heads",
"deferred_accounting_settings_section",
@@ -44,7 +44,6 @@
"print_settings",
"show_inclusive_tax_in_print",
"show_taxes_as_table_in_print",
"column_break_12",
"show_payment_schedule_in_print",
"item_price_settings_section",
"maintain_same_internal_transaction_rate",
@@ -60,39 +59,41 @@
"payments_tab",
"section_break_jpd0",
"auto_reconcile_payments",
"exchange_gain_loss_posting_date",
"auto_reconciliation_job_trigger",
"reconciliation_queue_size",
"column_break_resa",
"exchange_gain_loss_posting_date",
"repost_section",
"column_break_mfor",
"repost_allowed_types",
"payment_options_section",
"fetch_payment_schedule_in_payment_request",
"enable_loyalty_point_program",
"column_break_ctam",
"fetch_payment_schedule_in_payment_request",
"invoicing_settings_tab",
"accounts_transactions_settings_section",
"over_billing_allowance",
"column_break_11",
"role_allowed_to_over_bill",
"credit_controller",
"make_payment_via_journal_entry",
"over_billing_allowance",
"credit_controller",
"role_allowed_to_over_bill",
"column_break_11",
"assets_tab",
"asset_settings_section",
"calculate_depr_using_total_days",
"column_break_gjcc",
"book_asset_depreciation_entry_automatically",
"calculate_depr_using_total_days",
"role_to_notify_on_depreciation_failure",
"column_break_gjcc",
"closing_settings_tab",
"period_closing_settings_section",
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
"pcv_job_timeout",
"column_break_25",
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"column_break_lvjk",
"receivable_payable_remarks_length",
"column_break_lvjk",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"default_ageing_range",
@@ -104,13 +105,15 @@
"show_balance_in_coa",
"banking_section",
"enable_party_matching",
"automatically_run_rules_on_unreconciled_transactions",
"enable_fuzzy_matching",
"transfer_match_days",
"automatically_run_rules_on_unreconciled_transactions",
"payment_request_section",
"create_pr_in_draft_status",
"budget_section",
"use_legacy_budget_controller"
"use_legacy_budget_controller",
"document_naming_tab",
"transaction_naming_html"
],
"fields": [
{
@@ -118,14 +121,14 @@
"description": "Address used to determine Tax Category in transactions",
"fieldname": "determine_address_tax_category_from",
"fieldtype": "Select",
"label": "Determine Address Tax Category From",
"label": "Determine Address Tax Category from",
"options": "Billing Address\nShipping Address"
},
{
"fieldname": "credit_controller",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Role allowed to bypass Credit Limit",
"label": "Role allowed to bypass credit limit",
"options": "Role"
},
{
@@ -133,7 +136,7 @@
"description": "Enabling this ensures each Purchase Invoice has a unique value in Supplier Invoice No. field within a particular fiscal year",
"fieldname": "check_supplier_invoice_uniqueness",
"fieldtype": "Check",
"label": "Check Supplier Invoice Number Uniqueness"
"label": "Check Supplier invoice number uniqueness"
},
{
"default": "0",
@@ -144,27 +147,29 @@
},
{
"default": "1",
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#4-unlink-payment-on-cancellation-of-invoice",
"fieldname": "unlink_payment_on_cancellation_of_invoice",
"fieldtype": "Check",
"label": "Unlink Payment on Cancellation of Invoice"
"label": "Unlink Payment on cancellation of invoice"
},
{
"default": "1",
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#8-unlink-advance-payment-on-cancellation-of-order",
"fieldname": "unlink_advance_payment_on_cancelation_of_order",
"fieldtype": "Check",
"label": "Unlink Advance Payment on Cancellation of Order"
"label": "Unlink Advance Payment on cancellation of order"
},
{
"default": "1",
"fieldname": "book_asset_depreciation_entry_automatically",
"fieldtype": "Check",
"label": "Book Asset Depreciation Entry Automatically"
"label": "Book Asset Depreciation entry automatically"
},
{
"default": "1",
"fieldname": "add_taxes_from_item_tax_template",
"fieldtype": "Check",
"label": "Automatically Add Taxes and Charges from Item Tax Template"
"label": "Automatically add Taxes and Charges from Item Tax Template"
},
{
"fieldname": "print_settings",
@@ -175,17 +180,13 @@
"default": "0",
"fieldname": "show_inclusive_tax_in_print",
"fieldtype": "Check",
"label": "Show Inclusive Tax in Print"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
"label": "Show inclusive tax in print"
},
{
"default": "0",
"fieldname": "show_payment_schedule_in_print",
"fieldtype": "Check",
"label": "Show Payment Schedule in Print"
"label": "Show Payment Schedule in print"
},
{
"fieldname": "currency_exchange_section",
@@ -211,7 +212,7 @@
"description": "Payment Terms from orders will be fetched into the invoices as is",
"fieldname": "automatically_fetch_payment_terms",
"fieldtype": "Check",
"label": "Automatically Fetch Payment Terms from Order/Quotation"
"label": "Automatically fetch Payment Terms from Order/Quotation"
},
{
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
@@ -223,7 +224,7 @@
"default": "1",
"fieldname": "automatically_process_deferred_accounting_entry",
"fieldtype": "Check",
"label": "Automatically Process Deferred Accounting Entry"
"label": "Automatically process deferred Accounting entry"
},
{
"fieldname": "deferred_accounting_settings_section",
@@ -239,7 +240,7 @@
"description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense",
"fieldname": "book_deferred_entries_via_journal_entry",
"fieldtype": "Check",
"label": "Book Deferred Entries Via Journal Entry"
"label": "Book deferred entries via Journal Entry"
},
{
"default": "0",
@@ -247,38 +248,37 @@
"description": "If this is unchecked Journal Entries will be saved in a Draft state and will have to be submitted manually",
"fieldname": "submit_journal_entries",
"fieldtype": "Check",
"label": "Submit Journal Entries"
"label": "Submit Journal entries"
},
{
"default": "Days",
"description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month",
"fieldname": "book_deferred_entries_based_on",
"fieldtype": "Select",
"label": "Book Deferred Entries Based On",
"label": "Book Deferred entries based on",
"options": "Days\nMonths"
},
{
"default": "0",
"fieldname": "delete_linked_ledger_entries",
"fieldtype": "Check",
"label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction"
"label": "Delete Accounting and Stock Ledger entries on deletion of transaction"
},
{
"depends_on": "eval: doc.over_billing_allowance > 0",
"description": "Users with this role are allowed to over bill above the allowance percentage",
"fieldname": "role_allowed_to_over_bill",
"fieldtype": "Link",
"label": "Role Allowed to Over Bill ",
"label": "Role Allowed to over bill ",
"options": "Role"
},
{
"fieldname": "period_closing_settings_section",
"fieldtype": "Section Break",
"label": "Period Closing Settings"
"fieldtype": "Section Break"
},
{
"fieldname": "accounts_transactions_settings_section",
"fieldtype": "Section Break",
"label": "Credit Limit Settings"
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_11",
@@ -363,14 +363,14 @@
"default": "1",
"fieldname": "show_balance_in_coa",
"fieldtype": "Check",
"label": "Show Balances in Chart Of Accounts"
"label": "Show balances in Chart of Accounts"
},
{
"default": "0",
"description": "Split Early Payment Discount Loss into Income and Tax Loss",
"fieldname": "book_tax_discount_loss",
"fieldtype": "Check",
"label": "Book Tax Loss on Early Payment Discount"
"label": "Book tax loss on early payment discount"
},
{
"fieldname": "journals_section",
@@ -382,7 +382,7 @@
"description": "Rows with Same Account heads will be merged on Ledger",
"fieldname": "merge_similar_account_heads",
"fieldtype": "Check",
"label": "Merge Similar Account Heads"
"label": "Merge similar Account Heads"
},
{
"fieldname": "section_break_jpd0",
@@ -393,13 +393,13 @@
"default": "0",
"fieldname": "auto_reconcile_payments",
"fieldtype": "Check",
"label": "Auto Reconcile Payments"
"label": "Auto reconcile Payments"
},
{
"default": "0",
"fieldname": "show_taxes_as_table_in_print",
"fieldtype": "Check",
"label": "Show Taxes as Table in Print"
"label": "Show taxes as table in print"
},
{
"default": "0",
@@ -421,14 +421,14 @@
"description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ",
"fieldname": "ignore_account_closing_balance",
"fieldtype": "Check",
"label": "Ignore Account Closing Balance"
"label": "Ignore Account closing balance"
},
{
"default": "0",
"description": "Tax Amount will be rounded on a row(items) level",
"fieldname": "round_row_wise_tax",
"fieldtype": "Check",
"label": "Round Tax Amount Row-wise"
"label": "Round tax amount row-wise"
},
{
"fieldname": "reports_tab",
@@ -440,14 +440,14 @@
"description": "Truncates 'Remarks' column to set character length",
"fieldname": "general_ledger_remarks_length",
"fieldtype": "Int",
"label": "General Ledger"
"label": "General Ledger remarks length"
},
{
"default": "0",
"description": "Truncates 'Remarks' column to set character length",
"fieldname": "receivable_payable_remarks_length",
"fieldtype": "Int",
"label": "Accounts Receivable/Payable"
"label": "Accounts Receivable / Payable remarks length"
},
{
"fieldname": "column_break_lvjk",
@@ -481,7 +481,7 @@
"description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
"fieldname": "create_pr_in_draft_status",
"fieldtype": "Check",
"label": "Create in Draft Status"
"label": "Create payment requests in Draft status"
},
{
"fieldname": "column_break_yuug",
@@ -496,14 +496,14 @@
"description": "Interval should be between 1 to 59 MInutes",
"fieldname": "auto_reconciliation_job_trigger",
"fieldtype": "Int",
"label": "Auto Reconciliation Job Trigger"
"label": "Auto Reconciliation job trigger"
},
{
"default": "5",
"description": "Documents Processed on each trigger. Queue Size should be between 5 and 100",
"fieldname": "reconciliation_queue_size",
"fieldtype": "Int",
"label": "Reconciliation Queue Size"
"label": "Reconciliation queue size"
},
{
"default": "0",
@@ -517,14 +517,14 @@
"description": "Only applies for Normal Payments",
"fieldname": "exchange_gain_loss_posting_date",
"fieldtype": "Select",
"label": "Posting Date Inheritance for Exchange Gain / Loss",
"label": "Posting Date inheritance for exchange gain / loss",
"options": "Invoice\nPayment\nReconciliation Date"
},
{
"default": "Buffered Cursor",
"fieldname": "receivable_payable_fetch_method",
"fieldtype": "Select",
"label": "Data Fetch Method",
"label": "Data fetch method",
"options": "Buffered Cursor\nUnBuffered Cursor"
},
{
@@ -541,14 +541,14 @@
"default": "0",
"fieldname": "maintain_same_internal_transaction_rate",
"fieldtype": "Check",
"label": "Maintain Same Rate Throughout Internal Transaction"
"label": "Maintain same rate throughout internal Transaction"
},
{
"default": "Stop",
"depends_on": "maintain_same_internal_transaction_rate",
"fieldname": "maintain_same_rate_action",
"fieldtype": "Select",
"label": "Action if Same Rate is Not Maintained Throughout Internal Transaction",
"label": "Action if same rate is not maintained throughout internal transaction",
"mandatory_depends_on": "maintain_same_internal_transaction_rate",
"options": "Stop\nWarn"
},
@@ -556,7 +556,7 @@
"depends_on": "eval: doc.maintain_same_internal_transaction_rate && doc.maintain_same_rate_action == 'Stop'",
"fieldname": "role_to_override_stop_action",
"fieldtype": "Link",
"label": "Role Allowed to Override Stop Action",
"label": "Role allowed to override stop action",
"options": "Role"
},
{
@@ -588,7 +588,7 @@
"description": "If no taxes are set, and Taxes and Charges Template is selected, the system will automatically apply the taxes from the chosen template.",
"fieldname": "add_taxes_from_taxes_and_charges_template",
"fieldtype": "Check",
"label": "Automatically Add Taxes from Taxes and Charges Template"
"label": "Automatically add taxes from Taxes and Charges Template"
},
{
"fieldname": "column_break_ntmi",
@@ -598,19 +598,28 @@
"default": "0",
"fieldname": "fetch_valuation_rate_for_internal_transaction",
"fieldtype": "Check",
"label": "Fetch Valuation Rate for Internal Transaction"
"label": "Fetch valuation rate for internal Transaction"
},
{
"default": "0",
"description": "Enable this if you are experiencing issues with the new budget controller. Uses the older budget validation logic",
"fieldname": "use_legacy_budget_controller",
"fieldtype": "Check",
"label": "Use Legacy Budget Controller"
"label": "Use legacy Budget Controller"
},
{
"default": "1",
"fieldname": "use_legacy_controller_for_pcv",
"fieldtype": "Check",
"label": "Use Legacy Controller For Period Closing Voucher"
"label": "Use legacy controller for Period Closing Voucher"
},
{
"default": "3600",
"depends_on": "eval: !doc.use_legacy_controller_for_pcv",
"description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
"fieldname": "pcv_job_timeout",
"fieldtype": "Int",
"label": "PCV Job Timeout (seconds)"
},
{
"description": "Users with this role will be notified if the asset depreciation gets failed",
@@ -628,7 +637,7 @@
{
"fieldname": "chart_of_accounts_section",
"fieldtype": "Section Break",
"label": "Chart Of Accounts"
"label": "Chart of Accounts"
},
{
"fieldname": "banking_section",
@@ -673,6 +682,7 @@
},
{
"default": "0",
"documentation_url": "https://docs.frappe.io/erpnext/loyalty-program",
"fieldname": "enable_loyalty_point_program",
"fieldtype": "Check",
"label": "Enable Loyalty Point Program"
@@ -699,7 +709,7 @@
"default": "1",
"fieldname": "fetch_payment_schedule_in_payment_request",
"fieldtype": "Check",
"label": "Fetch Payment Schedule In Payment Request"
"label": "Fetch Payment Schedule in Payment Request"
},
{
"default": "3",
@@ -724,7 +734,7 @@
{
"fieldname": "repost_allowed_types",
"fieldtype": "Table",
"label": "Allowed Doctypes",
"label": "Allowed DocTypes",
"options": "Repost Allowed Types"
},
{
@@ -732,7 +742,21 @@
"description": "Runs a preview check on save before submission without making any actual changes.",
"fieldname": "preview_mode",
"fieldtype": "Check",
"label": "Preview Mode"
"label": "Preview mode"
},
{
"fieldname": "document_naming_tab",
"fieldtype": "Tab Break",
"label": "Document Naming"
},
{
"fieldname": "transaction_naming_html",
"fieldtype": "HTML"
},
{
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
"fieldname": "column_break_mfor",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
@@ -741,7 +765,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-05-18 12:16:33.679345",
"modified": "2026-06-24 12:59:41.868865",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -90,6 +90,7 @@ class AccountsSettings(Document):
make_payment_via_journal_entry: DF.Check
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
pcv_job_timeout: DF.Int
preview_mode: DF.Check
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int

View File

@@ -22,11 +22,13 @@ class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
"""
def setUp(self):
self.create_company()
self.create_usd_receivable_account()
self.create_usd_payable_account()
self.create_item()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.supplier = "_Test Supplier"
self.item = "_Test Item"
self.cash = "Cash - _TC"
self.debtors_usd = "_Test Receivable USD - _TC"
self.creditors_usd = "_Test Payable USD - _TC"
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
"""

View File

@@ -27,6 +27,7 @@
"column_break_12",
"branch_code",
"bank_account_no",
"statement_password",
"address_and_contact",
"address_html",
"column_break_13",
@@ -149,6 +150,12 @@
"label": "Bank Account No",
"length": 30
},
{
"description": "Password used to open password-protected PDF statements for this account. Stored encrypted.",
"fieldname": "statement_password",
"fieldtype": "Password",
"label": "Statement PDF Password"
},
{
"fieldname": "address_and_contact",
"fieldtype": "Section Break",

View File

@@ -41,6 +41,7 @@ class BankAccount(Document):
mask: DF.Data | None
party: DF.DynamicLink | None
party_type: DF.Link | None
statement_password: DF.Password | None
# end: auto-generated types
def onload(self):

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"creation": "2026-04-11 19:48:13.622253",
"doctype": "DocType",
@@ -7,7 +8,8 @@
"field_order": [
"bank_account",
"date",
"balance"
"balance",
"company"
],
"fields": [
{
@@ -31,12 +33,20 @@
"in_list_view": 1,
"label": "Balance",
"reqd": 1
},
{
"fetch_from": "bank_account.company",
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-04-11 19:49:45.374695",
"modified": "2026-06-16 22:17:48.007982",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account Balance",

View File

@@ -16,6 +16,7 @@ class BankAccountBalance(Document):
balance: DF.Currency
bank_account: DF.Link
company: DF.Link | None
date: DF.Date
# end: auto-generated types

View File

@@ -7,7 +7,7 @@ from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Coalesce, Sum
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import cint, flt, fmt_money, getdate
from pypika import Order
@@ -94,6 +94,7 @@ class BankClearance(Document):
invalid_document = []
invalid_cheque_date = []
entries_to_update = []
self.check_permission("write")
def validate_entry(d):
is_valid = True
@@ -194,14 +195,17 @@ def get_payment_entries_for_bank_clearance(
.select(
ConstantColumn("Journal Entry").as_("payment_document"),
journal_entry.name.as_("payment_entry"),
journal_entry.cheque_no.as_("cheque_number"),
journal_entry.cheque_date,
# non-grouped columns are constant per grouped JE name / account (against_account is
# arbitrary per group on MySQL) -> Max() keeps the GROUP BY valid on postgres with the
# same value MySQL picked.
Max(journal_entry.cheque_no).as_("cheque_number"),
Max(journal_entry.cheque_date).as_("cheque_date"),
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
journal_entry.posting_date,
journal_entry_account.against_account,
journal_entry.clearance_date,
journal_entry_account.account_currency,
Max(journal_entry.posting_date).as_("posting_date"),
Max(journal_entry_account.against_account).as_("against_account"),
Max(journal_entry.clearance_date).as_("clearance_date"),
Max(journal_entry_account.account_currency).as_("account_currency"),
)
.where(
(journal_entry_account.account == account)
@@ -214,12 +218,13 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
journal_entry_query = journal_entry_query.where(
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
(journal_entry.clearance_date.isnull())
| (journal_entry.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
journal_entries = (
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
.orderby(journal_entry.posting_date)
.orderby(Max(journal_entry.posting_date))
.orderby(journal_entry.name, order=Order.desc)
).run(as_dict=True)
@@ -289,7 +294,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
payment_entry_query = payment_entry_query.where(
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
(pe.clearance_date.isnull())
| (pe.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
@@ -326,7 +332,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
(pi.clearance_date.isnull())
| (pi.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
paid_purchase_invoices = (
@@ -366,7 +373,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
pos_sales_invoices_query = pos_sales_invoices_query.where(
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
(si_payment.clearance_date.isnull())
| (si_payment.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
pos_sales_invoices = (

View File

@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.set_query("reference_doctype", function () {
return {
filters: {
name: ["in", ["Sales Order", "Purchase Order"]],
},
};
});
frm.set_query("bank_account", function () {
return {
filters: {

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "ACC-BG-.YYYY.-.#####",
"creation": "2016-12-17 10:43:35.731631",
"doctype": "DocType",
@@ -50,8 +51,7 @@
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Reference Document Type",
"options": "DocType",
"read_only": 1
"options": "DocType"
},
{
"fieldname": "reference_docname",
@@ -60,14 +60,14 @@
"options": "reference_doctype"
},
{
"depends_on": "eval: doc.bg_type == \"Receiving\"",
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
},
{
"depends_on": "eval: doc.bg_type == \"Providing\"",
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
"fieldname": "supplier",
"fieldtype": "Link",
"label": "Supplier",
@@ -218,10 +218,11 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2025-08-29 11:52:33.550847",
"modified": "2026-05-25 18:12:10.768835",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Guarantee",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -8,7 +8,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Sum
from frappe.utils import cint, create_batch, flt
from erpnext import get_default_cost_center
@@ -518,6 +518,7 @@ def create_internal_transfer(
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_transaction.check_permission("write")
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
company = frappe.get_cached_value("Account", bank_account, "company")
@@ -778,7 +779,6 @@ def create_bulk_payment_entry_and_reconcile(
"""
Create a payment entry and reconcile it with the bank transaction
"""
output = []
for bank_transaction_name in bank_transaction_names:
@@ -1058,9 +1058,9 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
@frappe.whitelist()
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str, is_new_voucher: bool = False):
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
# updated clear date of all the vouchers based on the bank transaction
vouchers = json.loads(vouchers)
vouchers = frappe.parse_json(vouchers)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
transaction.add_payment_entries(vouchers, is_new_voucher)
transaction.validate_duplicate_references()
@@ -1410,12 +1410,14 @@ def get_je_matching_query(
Sum(getattr(jea, amount_field)).as_("paid_amount"),
ConstantColumn("Journal Entry").as_("doctype"),
je.name,
je.cheque_no.as_("reference_no"),
je.cheque_date.as_("reference_date"),
je.pay_to_recd_from.as_("party"),
jea.party_type,
je.posting_date,
jea.account_currency.as_("currency"),
# non-grouped columns are constant per grouped JE name (party_type/currency come from the
# single bank-account line) -> Max() keeps the GROUP BY valid on postgres with the same value
Max(je.cheque_no).as_("reference_no"),
Max(je.cheque_date).as_("reference_date"),
Max(je.pay_to_recd_from).as_("party"),
Max(jea.party_type).as_("party_type"),
Max(je.posting_date).as_("posting_date"),
Max(jea.account_currency).as_("currency"),
)
.where(je.docstatus == 1)
.where(je.voucher_type != "Opening Entry")
@@ -1423,7 +1425,7 @@ def get_je_matching_query(
.where(jea.account == common_filters.bank_account)
.where(filter_by_date)
.groupby(je.name)
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
.orderby(Max(je.cheque_date) if cint(filter_by_reference_date) else Max(je.posting_date))
)
if frappe.flags.auto_reconcile_vouchers is True:

View File

@@ -17,9 +17,10 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.bank = "HDFC - _TC"
self.debit_to = "Debtors - _TC"
bank_dt = qb.DocType("Bank")
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
self.create_bank_account()

View File

@@ -221,12 +221,12 @@
"default": "0",
"fieldname": "import_mt940_fromat",
"fieldtype": "Check",
"label": "Import MT940 Fromat"
"label": "Import MT940 Format"
}
],
"hide_toolbar": 1,
"links": [],
"modified": "2026-05-31 00:41:11.251215",
"modified": "2026-06-19 14:18:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import",

View File

@@ -290,7 +290,7 @@ def update_mapping_db(bank, template_options):
for d in bank.bank_transaction_mapping:
d.delete()
for d in json.loads(template_options)["column_to_field_map"].items():
for d in frappe.parse_json(template_options)["column_to_field_map"].items():
bank.append("bank_transaction_mapping", {"bank_transaction_field": d[1], "file_field": d[0]})
bank.save()

View File

@@ -28,7 +28,8 @@
"detected_transaction_starting_index",
"detected_transaction_ending_index",
"section_break_yulq",
"column_mapping"
"column_mapping",
"pdf_tables"
],
"fields": [
{
@@ -128,6 +129,13 @@
"label": "Column Mapping",
"options": "Bank Statement Import Log Column Map"
},
{
"description": "Per-table extraction data for PDF statements (rows, bbox, page image, column mapping). Edited via the banking app.",
"fieldname": "pdf_tables",
"fieldtype": "JSON",
"label": "PDF Tables",
"read_only": 1
},
{
"default": "Not Started",
"fieldname": "status",

View File

@@ -7,7 +7,18 @@ from frappe.utils import getdate
from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log import (
BankStatementImportLog,
build_table_transactions,
detect_column_mapping,
detect_header_row,
extract_pdf_tables,
get_float_amount,
get_statement_details,
guess_column_mapping_by_content,
reextract_pdf_table,
set_header_index,
set_pdf_table_header,
update_column_mapping,
update_pdf_tables,
)
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
@@ -15,9 +26,9 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.bank = "HDFC - _TC"
bank_dt = qb.DocType("Bank")
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
self.create_bank_account()
@@ -113,6 +124,346 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
self.assertIsNone(get_float_amount("ABCD"))
self.assertIsNone(get_float_amount("****"))
# ------------------------------------------------------------------ #
# PDF statement import
# ------------------------------------------------------------------ #
@staticmethod
def _make_pdf(html: str) -> bytes:
import pdfkit
return pdfkit.from_string(html, False)
@staticmethod
def _encrypt(pdf_bytes: bytes, password: str) -> bytes:
import io
from pypdf import PdfReader, PdfWriter
reader = PdfReader(io.BytesIO(pdf_bytes))
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
writer.encrypt(password)
buffer = io.BytesIO()
writer.write(buffer)
return buffer.getvalue()
@staticmethod
def _auto_map(table: dict) -> dict:
"""Mimic prepare_pdf_tables' best-effort mapping for a single extracted table."""
header_index, score = detect_header_row(table["rows"])
if score >= 2:
table["header_index"] = header_index
table["column_mapping"] = detect_column_mapping(table["rows"][header_index])
else:
table["header_index"] = None
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
table["included"] = True
return table
def test_pdf_multi_page_kept_separate_and_unioned(self):
"""Tables on separate pages must NOT be merged; transactions are the union."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td><td></td><td>9500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td></td><td>20000.00</td><td>29500.00</td></tr></table>
<div style="page-break-before: always"></div>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td><td></td><td>27500.00</td></tr></table>
</body></html>
"""
tables = extract_pdf_tables(self._make_pdf(html))
# Two separate tables, one per page
self.assertEqual(len(tables), 2)
self.assertEqual(sorted(t["page"] for t in tables), [1, 2])
for table in tables:
self.assertIn("bbox", table)
self.assertEqual(len(table["bbox"]), 4)
union = []
for table in tables:
final, _df, _af = build_table_transactions(self._auto_map(table))
union.extend(final)
self.assertEqual(len(union), 3)
self.assertEqual(sorted(t["date"] for t in union), ["2024-04-01", "2024-04-03", "2024-04-05"])
def test_pdf_junk_table_excluded(self):
"""A non-transactions table (ad/summary) should yield zero transactions."""
ad_table = self._auto_map({"rows": [["Open a new account!", "Call 1800-XYZ"]]})
final, _df, _af = build_table_transactions(ad_table)
self.assertEqual(final, [])
def test_headerless_content_mapping(self):
"""Without a header row, columns are guessed from their contents."""
rows = [
["01/04/2024", "UPI PAYMENT", "500.00"],
["03/04/2024", "SALARY CREDIT", "20000.00"],
]
mapping = {
c["maps_to"]: c["index"]
for c in guess_column_mapping_by_content(rows)
if c["maps_to"] != "Do not import"
}
self.assertEqual(mapping.get("Date"), 0)
self.assertEqual(mapping.get("Description"), 1)
self.assertEqual(mapping.get("Amount"), 2)
def test_pdf_password_protected(self):
"""Encrypted PDFs error without a password and succeed with the right one."""
html = """
<html><body><table border="1">
<tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr></table></body></html>
"""
encrypted = self._encrypt(self._make_pdf(html), "secret123")
# No / wrong password -> recognizable error
self.assertRaises(frappe.ValidationError, extract_pdf_tables, encrypted)
self.assertRaises(frappe.ValidationError, extract_pdf_tables, encrypted, "wrong")
# Correct password -> extracts
tables = extract_pdf_tables(encrypted, "secret123")
self.assertTrue(tables)
def test_pdf_no_tables_detected(self):
"""A PDF with no detectable tables raises a clear error (e.g. scanned PDFs)."""
html = "<html><body><p>Just some prose with no tabular data at all.</p></body></html>"
self.assertRaises(frappe.ValidationError, extract_pdf_tables, self._make_pdf(html))
def _create_pdf_import_log(self, html: str) -> BankStatementImportLog:
pdf_bytes = self._make_pdf(html)
file_doc = frappe.get_doc(
{
"doctype": "File",
"file_name": f"test-statement-{frappe.generate_hash(length=8)}.pdf",
"is_private": 1,
"content": pdf_bytes,
}
).insert(ignore_permissions=True)
doc = frappe.get_doc(
{
"doctype": "Bank Statement Import Log",
"name": f"test-pdf-{frappe.generate_hash(length=8)}",
"bank_account": self.bank_account,
"file": file_doc.file_url,
}
)
return doc.insert()
def test_pdf_full_lifecycle(self):
"""End-to-end doc lifecycle: insert -> rasterize -> preview -> edit -> import."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td><td></td><td>9500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td></td><td>20000.00</td><td>29500.00</td></tr></table>
<div style="page-break-before: always"></div>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td><td></td><td>27500.00</td></tr></table>
</body></html>
"""
doc = self._create_pdf_import_log(html)
# before_insert populated the per-table JSON, page images and the union summary
tables = doc.get_pdf_tables()
self.assertEqual(len(tables), 2)
for table in tables:
self.assertTrue(table.get("page_image"))
self.assertIn("bbox", table)
# Page-image File must be attached to the final docname, not the client's temp id
attached_to = frappe.db.get_value("File", {"file_url": table["page_image"]}, "attached_to_name")
self.assertEqual(attached_to, doc.name)
self.assertEqual(doc.number_of_transactions, 3)
self.assertEqual(doc.total_debit_transactions, 2)
self.assertEqual(doc.total_credit_transactions, 1)
# get_statement_details returns the union and the per-table data for the editor
details = get_statement_details(doc.name)
self.assertEqual(len(details["final_transactions"]), 3)
self.assertEqual(details["raw_data"], [])
self.assertEqual(len(details["pdf_tables"]), 2)
# Excluding the second table (page 2) drops its single transaction
tables[1]["included"] = False
update_pdf_tables(doc.name, tables)
doc.reload()
self.assertEqual(doc.number_of_transactions, 2)
# Re-include and import; transactions are created for the union
tables[1]["included"] = True
update_pdf_tables(doc.name, tables)
doc.reload()
doc.insert_transactions()
doc.reload()
self.assertEqual(doc.status, "Completed")
created = frappe.get_all(
"Bank Transaction", filters={"bank_account": self.bank_account, "docstatus": 1}
)
self.assertEqual(len(created), 3)
def test_pdf_reextract_table_from_bbox(self):
"""Re-extracting a table from an adjusted bbox updates its rows and stores the bbox."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr></table>
</body></html>
"""
doc = self._create_pdf_import_log(html)
table = doc.get_pdf_tables()[0]
bbox = table["bbox"]
details = reextract_pdf_table(doc.name, table["page"], table["table_index"], bbox)
updated = details["pdf_tables"][0]
# Same region -> same rows; bbox is persisted
self.assertTrue(updated["rows"])
self.assertEqual(updated["bbox"], [round(float(v), 2) for v in bbox])
self.assertEqual(updated["rows"], table["rows"])
def test_pdf_reextract_changed_bbox_updates_rows_and_transactions(self):
"""Shrinking a table's bbox must drop rows and update the transaction count end-to-end."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr>
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td></tr>
<tr><td>07/04/2024</td><td>INTEREST</td><td>12.50</td></tr></table>
</body></html>
"""
doc = self._create_pdf_import_log(html)
original = doc.get_pdf_tables()[0]
original_rows = len(original["rows"])
original_txns = doc.number_of_transactions
# Shrink the box to roughly the top half (simulating a user drag).
x0, top, x1, bottom = original["bbox"]
shrunk = [x0, top, x1, top + (bottom - top) * 0.5]
details = reextract_pdf_table(doc.name, original["page"], original["table_index"], shrunk)
updated = details["pdf_tables"][0]
doc.reload()
self.assertLess(len(updated["rows"]), original_rows)
self.assertLess(doc.number_of_transactions, original_txns)
self.assertEqual(len(details["final_transactions"]), doc.number_of_transactions)
def test_pdf_set_table_header(self):
"""User can clear a table's header (no header row) or set a specific header row."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr></table>
</body></html>
"""
doc = self._create_pdf_import_log(html)
table = doc.get_pdf_tables()[0]
self.assertEqual(table["header_index"], 0)
original = {
c["maps_to"]: c["index"] for c in table["column_mapping"] if c["maps_to"] != "Do not import"
}
# Clear the header (-1): header is removed but the mapping is preserved (not re-guessed).
details = set_pdf_table_header(doc.name, table["page"], table["table_index"], -1)
updated = details["pdf_tables"][0]
self.assertIsNone(updated["header_index"])
preserved = {
c["maps_to"]: c["index"] for c in updated["column_mapping"] if c["maps_to"] != "Do not import"
}
self.assertEqual(preserved, original)
# Set row 0 back as the header: it resolves meaningfully, so mapping is re-derived.
details = set_pdf_table_header(doc.name, table["page"], table["table_index"], 0)
updated = details["pdf_tables"][0]
self.assertEqual(updated["header_index"], 0)
mapped = {
c["maps_to"]: c["index"] for c in updated["column_mapping"] if c["maps_to"] != "Do not import"
}
self.assertEqual(mapped.get("Date"), 0)
self.assertEqual(mapped.get("Description"), 1)
# ------------------------------------------------------------------ #
# CSV/XLSX column mapping + header overrides
# ------------------------------------------------------------------ #
def _create_csv_import_log(self, csv_text: str) -> BankStatementImportLog:
file_doc = frappe.get_doc(
{
"doctype": "File",
"file_name": f"test-statement-{frappe.generate_hash(length=8)}.csv",
"is_private": 1,
"content": csv_text,
}
).insert(ignore_permissions=True)
doc = frappe.get_doc(
{
"doctype": "Bank Statement Import Log",
"bank_account": self.bank_account,
"file": file_doc.file_url,
}
)
return doc.insert()
def test_csv_update_column_mapping(self):
"""Overriding the column mapping recomputes the transaction count."""
csv_text = "Date,Narration,Amount\n01/04/2024,UPI PAYMENT,500.00\n03/04/2024,SALARY,20000.00\n"
doc = self._create_csv_import_log(csv_text)
self.assertEqual(doc.number_of_transactions, 2)
# Drop the amount column -> no amount -> no transactions detected.
mapping = [
{"index": c.index, "maps_to": "Do not import" if c.maps_to == "Amount" else c.maps_to}
for c in doc.column_mapping
]
details = update_column_mapping(doc.name, mapping)
doc.reload()
self.assertEqual(doc.number_of_transactions, 0)
self.assertEqual(len(details["final_transactions"]), 0)
def test_csv_set_header_index_preserves_mapping(self):
"""Clearing the header keeps the user's mapping; it is not re-guessed."""
csv_text = "Date,Narration,Amount\n01/04/2024,UPI PAYMENT,500.00\n03/04/2024,SALARY,20000.00\n"
doc = self._create_csv_import_log(csv_text)
self.assertEqual(doc.detected_header_index, 0)
# Manually map the Narration column (1) as Reference.
mapping = [
{
"index": c.index,
"maps_to": "Reference" if c.index == 1 else c.maps_to,
"header_text": c.header_text,
}
for c in doc.column_mapping
]
update_column_mapping(doc.name, mapping)
doc.reload()
# Clear the header row: the manual mapping must be preserved (column 1 stays Reference,
# not re-guessed to Description). The label row fails date parsing, so 2 transactions remain.
set_header_index(doc.name, -1)
doc.reload()
self.assertEqual(doc.detected_header_index, -1)
self.assertEqual(doc.number_of_transactions, 2)
current = {c.index: c.maps_to for c in doc.column_mapping}
self.assertEqual(current.get(1), "Reference")
# Restore row 0 as the header (resolves meaningfully -> re-derived from labels).
set_header_index(doc.name, 0)
doc.reload()
self.assertEqual(doc.detected_header_index, 0)
restored = {c.maps_to: c.index for c in doc.column_mapping if c.maps_to != "Do not import"}
self.assertEqual(restored.get("Description"), 1)
test_hdfc_sample_statement_data = [
["HDFC BANK Ltd. Page No .: 1 Statement of accounts", "", "", "", "", "", ""],

View File

@@ -5,6 +5,8 @@ import frappe
from frappe import _
from frappe.model.docstatus import DocStatus
from frappe.model.document import Document
from frappe.query_builder import Tuple
from frappe.query_builder.functions import Abs, Max, Sum
from frappe.utils import flt, getdate
@@ -374,6 +376,7 @@ def unreconcile_transaction(transaction_name: str | int):
Else, cancel the individual entries
"""
transaction = frappe.get_doc("Bank Transaction", transaction_name)
transaction.check_permission("write")
vouchers_to_cancel = []
@@ -401,6 +404,7 @@ def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type:
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
bank_transaction.check_permission("write")
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
for entry in bank_transaction.payment_entries:
@@ -436,7 +440,7 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
if bt_bank_account != gl_bank_account:
frappe.throw(
_("Bank Account {} in Bank Transaction {} is not matching with Bank Account {}").format(
_("Bank Account {0} in Bank Transaction {1} is not matching with Bank Account {2}").format(
bt_bank_account, payment_entry.payment_entry, gl_bank_account
)
)
@@ -445,7 +449,7 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
if gl_bank_account not in gl_entries:
frappe.throw(
_("{} {} is not affecting bank account {}").format(
_("{0} {1} is not affecting bank account {2}").format(
payment_entry.payment_document, payment_entry.payment_entry, gl_bank_account
)
)
@@ -453,7 +457,7 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
allocable_amount = gl_entries.pop(gl_bank_account) or 0
if allocable_amount <= 0.0:
frappe.throw(
_("Invalid amount in accounting entries of {} {} for Account {}: {}").format(
_("Invalid amount in accounting entries of {0} {1} for Account {2}: {3}").format(
payment_entry.payment_document, payment_entry.payment_entry, gl_bank_account, allocable_amount
)
)
@@ -476,30 +480,28 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
def get_related_bank_gl_entries(docs):
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
if not docs:
return {}
result = frappe.db.sql(
"""
SELECT
gle.voucher_type AS doctype,
gle.voucher_no AS docname,
gle.account AS gl_account,
SUM(ABS(gle.credit_in_account_currency - gle.debit_in_account_currency)) AS amount
FROM
`tabGL Entry` gle
LEFT JOIN
`tabAccount` ac ON ac.name = gle.account
WHERE
ac.account_type = 'Bank'
AND (gle.voucher_type, gle.voucher_no) IN %(docs)s
AND gle.is_cancelled = 0
GROUP BY
gle.voucher_type, gle.voucher_no, gle.account
""",
{"docs": docs},
as_dict=True,
gle = frappe.qb.DocType("GL Entry")
ac = frappe.qb.DocType("Account")
result = (
frappe.qb.from_(gle)
.left_join(ac)
.on(ac.name == gle.account)
.select(
gle.voucher_type.as_("doctype"),
gle.voucher_no.as_("docname"),
gle.account.as_("gl_account"),
Sum(Abs(gle.credit_in_account_currency - gle.debit_in_account_currency)).as_("amount"),
)
.where(
(ac.account_type == "Bank")
& Tuple(gle.voucher_type, gle.voucher_no).isin([Tuple(vt, vn) for vt, vn in docs])
& (gle.is_cancelled == 0)
)
.groupby(gle.voucher_type, gle.voucher_no, gle.account)
.run(as_dict=True)
)
entries = {}
@@ -521,31 +523,32 @@ def get_total_allocated_amount(docs):
if not docs:
return {}
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
result = frappe.db.sql(
"""
SELECT total, latest_date, gl_account, payment_document, payment_entry FROM (
SELECT
ROW_NUMBER() OVER w AS rownum,
SUM(btp.allocated_amount) OVER(PARTITION BY ba.account, btp.payment_document, btp.payment_entry) AS total,
FIRST_VALUE(bt.date) OVER w AS latest_date,
ba.account AS gl_account,
btp.payment_document,
btp.payment_entry
FROM
`tabBank Transaction Payments` btp
LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent
LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account
WHERE
(btp.payment_document, btp.payment_entry) IN %(docs)s
AND bt.docstatus = 1
WINDOW w AS (PARTITION BY ba.account, btp.payment_document, btp.payment_entry ORDER BY bt.date DESC)
) temp
WHERE
rownum = 1
""",
dict(docs=docs),
as_dict=True,
# The original window query (ROW_NUMBER/FIRST_VALUE + rownum = 1) just collapses to one
# row per (account, payment_document, payment_entry) with the partition's allocation total
# and most recent transaction date — i.e. a plain GROUP BY with SUM and MAX.
btp = frappe.qb.DocType("Bank Transaction Payments")
bt = frappe.qb.DocType("Bank Transaction")
ba = frappe.qb.DocType("Bank Account")
result = (
frappe.qb.from_(btp)
.left_join(bt)
.on(bt.name == btp.parent)
.left_join(ba)
.on(ba.name == bt.bank_account)
.select(
Sum(btp.allocated_amount).as_("total"),
Max(bt.date).as_("latest_date"),
ba.account.as_("gl_account"),
btp.payment_document,
btp.payment_entry,
)
.where(
Tuple(btp.payment_document, btp.payment_entry).isin([Tuple(pd, pe) for pd, pe in docs])
& (bt.docstatus == 1)
)
.groupby(ba.account, btp.payment_document, btp.payment_entry)
.run(as_dict=True)
)
payment_allocation_details = {}

View File

@@ -35,12 +35,12 @@ def upload_bank_statement():
@frappe.whitelist()
def create_bank_entries(columns: str, data: str, bank_account: str):
def create_bank_entries(columns: str, data: str | list, bank_account: str):
header_map = get_header_mapping(columns, bank_account)
success = 0
errors = 0
for d in json.loads(data):
for d in frappe.parse_json(data):
if all(item is None for item in d) is True:
continue
fields = {}
@@ -66,7 +66,7 @@ def get_header_mapping(columns, bank_account):
mapping = get_bank_mapping(bank_account)
header_map = {}
for column in json.loads(columns):
for column in frappe.parse_json(columns):
if column["content"] in mapping:
header_map.update({mapping[column["content"]]: column["colIndex"]})

View File

@@ -47,7 +47,7 @@ class TestBankTransaction(ERPNextTestSuite):
from_date=bank_transaction.date,
to_date=utils.today(),
)
self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
self.assertEqual(linked_payments[0]["party"], "Conrad Electronic")
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
def test_reconcile(self):
@@ -70,10 +70,10 @@ class TestBankTransaction(ERPNextTestSuite):
unallocated_amount = frappe.db.get_value(
"Bank Transaction", bank_transaction.name, "unallocated_amount"
)
self.assertTrue(unallocated_amount == 0)
self.assertEqual(unallocated_amount, 0)
clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date")
self.assertTrue(clearance_date is not None)
self.assertIsNot(clearance_date, None)
bank_transaction.reload()
bank_transaction.cancel()
@@ -104,6 +104,36 @@ class TestBankTransaction(ERPNextTestSuite):
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
# Amending a reconciled payment entry must not carry over its clearance date
def test_clearance_date_cleared_on_amend(self):
bank_transaction = frappe.get_doc(
"Bank Transaction",
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
)
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
vouchers = json.dumps(
[
{
"payment_doctype": "Payment Entry",
"payment_name": payment.name,
"amount": bank_transaction.unallocated_amount,
}
]
)
reconcile_vouchers(bank_transaction.name, vouchers)
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
payment.reload()
payment.cancel()
amended = frappe.copy_doc(payment)
amended.amended_from = payment.name
amended.docstatus = 0
amended.insert()
self.assertFalse(amended.clearance_date)
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc(
@@ -178,9 +208,8 @@ class TestBankTransaction(ERPNextTestSuite):
self.assertEqual(
frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount"), 0
)
self.assertTrue(
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date")
is not None
self.assertIsNot(
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date"), None
)
@if_lending_app_installed

View File

@@ -66,7 +66,7 @@ class BankTransactionRule(Document):
frappe.throw(_("Party type is required to create a payment entry."))
if not self.party:
frappe.throw(_("Party is required create a payment entry."))
frappe.throw(_("Party is required to create a payment entry."))
if not self.account:
frappe.throw(_("Party account is required to create a payment entry."))

View File

@@ -11,9 +11,11 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.bank = "HDFC - _TC"
self.debit_to = "Debtors - _TC"
self.cash = "Cash - _TC"
bank_dt = qb.DocType("Bank")
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
self.create_bank_account()

View File

@@ -121,7 +121,7 @@ class BisectAccountingStatements(Document):
cur_node.save()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def build_tree(self):
frappe.db.delete("Bisect Nodes")

View File

@@ -17,6 +17,7 @@ frappe.ui.form.on("Budget", {
filters: {
is_group: 0,
company: frm.doc.company,
root_type: ["in", ["Income", "Expense"]],
},
};
});
@@ -135,6 +136,9 @@ function set_total_budget_amount(frm) {
function toggle_distribution_fields(frm) {
const grid = frm.fields_dict.budget_distribution.grid;
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
["amount", "percent"].forEach((field) => {
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
});

View File

@@ -5,9 +5,11 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Coalesce, Sum
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
from frappe.utils.data import get_first_day
from pypika.terms import ExistsCriterion
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -115,23 +117,26 @@ class Budget(Document):
if not account:
return
existing_budget = frappe.db.sql(
f"""
SELECT name, account
FROM `tabBudget`
WHERE
docstatus < 2
AND company = %s
AND {budget_against_field} = %s
AND account = %s
AND name != %s
AND (
(SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
)
""",
(self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date),
as_dict=True,
budget = frappe.qb.DocType("Budget")
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
existing_budget = (
frappe.qb.from_(budget)
.inner_join(fy_from)
.on(fy_from.name == budget.from_fiscal_year)
.inner_join(fy_to)
.on(fy_to.name == budget.to_fiscal_year)
.select(budget.name, budget.account)
.where(
(budget.docstatus < 2)
& (budget.company == self.company)
& (budget[budget_against_field] == budget_against)
& (budget.account == account)
& (budget.name != self.name)
& (fy_from.year_start_date <= self.budget_end_date)
& (fy_to.year_end_date >= self.budget_start_date)
)
.run(as_dict=True)
)
if existing_budget:
@@ -157,9 +162,9 @@ class Budget(Document):
frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company))
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format(
self.account
)
_(
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
).format(self.account)
)
def set_null_value(self):
@@ -353,8 +358,8 @@ class Budget(Document):
if self.should_regenerate_budget_distribution():
return
total_amount = sum(d.amount for d in self.budget_distribution)
total_percent = sum(d.percent for d in self.budget_distribution)
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
frappe.throw(
@@ -381,17 +386,24 @@ def validate_expense_against_budget(params, expense_amount=0):
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
budget_exists = frappe.db.sql(
"""
select name
from `tabBudget`
where company = %s
and docstatus = 1
and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
limit 1
""",
(params.company, year_end_date, year_start_date),
budget = frappe.qb.DocType("Budget")
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
budget_exists = (
frappe.qb.from_(budget)
.inner_join(fy_from)
.on(fy_from.name == budget.from_fiscal_year)
.inner_join(fy_to)
.on(fy_to.name == budget.to_fiscal_year)
.select(budget.name)
.where(
(budget.company == params.company)
& (budget.docstatus == 1)
& (fy_from.year_start_date <= year_end_date)
& (fy_to.year_end_date >= year_start_date)
)
.limit(1)
.run()
)
if not budget_exists:
@@ -434,50 +446,52 @@ def validate_expense_against_budget(params, expense_amount=0):
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
):
doctype = dimension.get("document_type")
if frappe.get_cached_value("DocType", doctype, "is_tree"):
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
condition = f"""and exists(select name from `tab{doctype}`
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
params.is_tree = True
else:
condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
params.is_tree = False
params.is_tree = bool(frappe.get_cached_value("DocType", doctype, "is_tree"))
params.budget_against_field = budget_against
params.budget_against_doctype = doctype
budget_records = frappe.db.sql(
f"""
SELECT
b = frappe.qb.DocType("Budget")
query = (
frappe.qb.from_(b)
.select(
b.name,
b.{budget_against} AS budget_against,
getattr(b, budget_against).as_("budget_against"),
b.budget_amount,
b.from_fiscal_year,
b.to_fiscal_year,
b.budget_start_date,
b.budget_end_date,
IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
Coalesce(b.applicable_on_material_request, 0).as_("for_material_request"),
Coalesce(b.applicable_on_purchase_order, 0).as_("for_purchase_order"),
Coalesce(b.applicable_on_booking_actual_expenses, 0).as_("for_actual_expenses"),
b.action_if_annual_budget_exceeded,
b.action_if_accumulated_monthly_budget_exceeded,
b.action_if_annual_budget_exceeded_on_mr,
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
b.action_if_annual_budget_exceeded_on_po,
b.action_if_accumulated_monthly_budget_exceeded_on_po
FROM
`tabBudget` b
WHERE
b.company = %s
AND b.docstatus = 1
AND %s BETWEEN b.budget_start_date AND b.budget_end_date
AND b.account = %s
{condition}
""",
(params.company, params.posting_date, params.account),
as_dict=True,
) # nosec
b.action_if_accumulated_monthly_budget_exceeded_on_po,
)
.where(b.company == params.company)
.where(b.docstatus == 1)
.where(b.budget_start_date <= params.posting_date)
.where(b.budget_end_date >= params.posting_date)
.where(b.account == params.account)
)
if params.is_tree:
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
dim = frappe.qb.DocType(doctype)
query = query.where(
ExistsCriterion(
frappe.qb.from_(dim)
.select(dim.name)
.where((dim.lft <= lft) & (dim.rgt >= rgt) & (dim.name == getattr(b, budget_against)))
)
)
else:
query = query.where(getattr(b, budget_against) == params.get(budget_against))
budget_records = query.run(as_dict=True)
if budget_records:
validate_budget_records(params, budget_records, expense_amount)
@@ -674,15 +688,27 @@ def get_actions(params, budget):
def get_requested_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Material Request")
data = frappe.db.sql(
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
from `tabMaterial Request Item` child, `tabMaterial Request` parent where parent.name = child.parent and
child.item_code = %s and parent.docstatus = 1 and child.stock_qty > child.ordered_qty and {} and
parent.material_request_type = 'Purchase' and parent.status != 'Stopped'""".format(condition),
item_code,
as_list=1,
child = frappe.qb.DocType("Material Request Item")
parent = frappe.qb.DocType("Material Request")
data = (
frappe.qb.from_(child)
.join(parent)
.on(parent.name == child.parent)
.select(
# rate inside the aggregate: Sum(qty * rate) is the correct requested amount and is PG-valid
Coalesce(Sum((child.stock_qty - child.ordered_qty) * child.rate), 0).as_("amount")
)
.where(
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.stock_qty > child.ordered_qty)
& Criterion.all(get_other_condition(params, child, parent, "Material Request"))
& (parent.material_request_type == "Purchase")
& (parent.status != "Stopped")
)
.run(as_list=1)
)
return data[0][0] if data else 0
@@ -690,35 +716,43 @@ def get_requested_amount(params):
def get_ordered_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Purchase Order")
data = frappe.db.sql(
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
from `tabPurchase Order Item` child, `tabPurchase Order` parent where
parent.name = child.parent and child.item_code = %s and parent.docstatus = 1 and child.amount > child.billed_amt
and parent.status != 'Closed' and {condition}""",
item_code,
as_list=1,
child = frappe.qb.DocType("Purchase Order Item")
parent = frappe.qb.DocType("Purchase Order")
data = (
frappe.qb.from_(child)
.join(parent)
.on(parent.name == child.parent)
.select(Coalesce(Sum(child.amount - child.billed_amt), 0).as_("amount"))
.where(
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.amount > child.billed_amt)
& (parent.status != "Closed")
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
)
.run(as_list=1)
)
return data[0][0] if data else 0
def get_other_condition(params, for_doc):
condition = f"expense_account = '{params.expense_account}'"
def get_other_condition(params, child, parent, for_doc):
conditions = [child.expense_account == params.expense_account]
budget_against_field = params.get("budget_against_field")
if budget_against_field and params.get(budget_against_field):
condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'"
conditions.append(child[budget_against_field] == params.get(budget_against_field))
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
conditions.append(parent[date_field][str(start_date) : str(end_date)])
return condition
return conditions
def get_actual_expense(params):
@@ -726,11 +760,19 @@ def get_actual_expense(params):
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
budget_against_field = params.get("budget_against_field")
condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
date_condition = (
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
)
gle = frappe.qb.DocType("GL Entry")
conditions = [
gle.is_cancelled == 0,
gle.account == params.get("account"),
gle.posting_date[str(params.budget_start_date) : str(params.budget_end_date)],
gle.company == params.get("company"),
gle.docstatus == 1,
]
if params.get("month_end_date"):
conditions.append(gle.posting_date <= params.get("month_end_date"))
if params.is_tree:
lft_rgt = frappe.db.get_value(
@@ -738,35 +780,27 @@ def get_actual_expense(params):
)
params.update(lft_rgt)
condition2 = f"""
and exists(
select name from `tab{params.budget_against_doctype}`
where lft >= %(lft)s and rgt <= %(rgt)s
and name = gle.{budget_against_field}
tree = frappe.qb.DocType(params.budget_against_doctype)
conditions.append(
ExistsCriterion(
frappe.qb.from_(tree)
.select(tree.name)
.where(
(tree.lft >= params.get("lft"))
& (tree.rgt <= params.get("rgt"))
& (tree.name == gle[budget_against_field])
)
)
"""
)
else:
condition2 = f"""
and gle.{budget_against_field} = %({budget_against_field})s
"""
conditions.append(gle[budget_against_field] == params.get(budget_against_field))
amount = flt(
frappe.db.sql(
f"""
select sum(gle.debit) - sum(gle.credit)
from `tabGL Entry` gle
where
is_cancelled = 0
and gle.account = %(account)s
{condition1}
{date_condition}
and gle.company = %(company)s
and gle.docstatus = 1
{condition2}
""",
params,
)[0][0]
) # nosec
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where(Criterion.all(conditions))
.run()[0][0]
)
return amount

View File

@@ -18,6 +18,7 @@
"in_list_view": 1,
"label": "Start Date",
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
@@ -25,26 +26,29 @@
"fieldtype": "Date",
"in_list_view": 1,
"label": "End Date",
"read_only": 1
"read_only": 1,
"reqd": 1
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount"
"label": "Amount",
"reqd": 1
},
{
"fieldname": "percent",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Percent"
"label": "Percent",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-03 13:18:28.398198",
"modified": "2026-06-18 11:23:17.669733",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget Distribution",

View File

@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
from frappe.types import DF
amount: DF.Currency
end_date: DF.Date | None
end_date: DF.Date
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
percent: DF.Percent
start_date: DF.Date | None
start_date: DF.Date
# end: auto-generated types
pass

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import flt
@@ -43,13 +44,17 @@ class CashierClosing(Document):
self.make_calculations()
def get_outstanding(self):
values = frappe.db.sql(
"""
select sum(outstanding_amount)
from `tabSales Invoice`
where posting_date=%s and posting_time>=%s and posting_time<=%s and owner=%s
""",
(self.date, self.from_time, self.time, self.user),
si = frappe.qb.DocType("Sales Invoice")
values = (
frappe.qb.from_(si)
.select(Sum(si.outstanding_amount))
.where(
(si.posting_date == self.date)
& (si.posting_time >= self.from_time)
& (si.posting_time <= self.time)
& (si.owner == self.user)
)
.run()
)
self.outstanding_amount = flt(values[0][0] if values else 0)

View File

@@ -63,8 +63,8 @@ def validate_company(company: str):
)
if parent_company and (not allow_account_creation_against_child_company):
msg = _("{} is a child company.").format(frappe.bold(company)) + " "
msg += _("Please import accounts against parent company or enable {} in company master.").format(
msg = _("{0} is a child company.").format(frappe.bold(company)) + " "
msg += _("Please import accounts against parent company or enable {0} in company master.").format(
frappe.bold(_("Allow Account Creation Against Child Company"))
)
frappe.throw(msg, title=_("Wrong Company"))
@@ -75,7 +75,10 @@ def validate_company(company: str):
@frappe.whitelist()
def import_coa(file_name: str, company: str):
frappe.only_for("Accounts Manager")
# delete existing data for accounts
frappe.has_permission("Company", "write", company, throw=True)
unset_existing_data(company)
# create accounts
@@ -453,6 +456,7 @@ def unset_existing_data(company):
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
linked = [{"fieldname": name} for name in fieldnames]
update_values = {d.get("fieldname"): "" for d in linked}
frappe.db.set_value("Company", company, update_values, update_values)
# remove accounts data from various doctypes
@@ -464,8 +468,7 @@ def unset_existing_data(company):
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
]:
dt = frappe.qb.DocType(doctype)
frappe.qb.from_(dt).where(dt.company == company).delete().run()
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
def set_default_accounts(company):

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

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "field:bank_name",
"creation": "2016-05-04 14:35:00.402544",
"doctype": "DocType",
@@ -294,7 +295,7 @@
],
"links": [],
"max_attachments": 1,
"modified": "2024-03-27 13:06:44.654989",
"modified": "2026-06-08 12:10:35.829531",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cheque Print Template",
@@ -325,19 +326,17 @@
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"write": 1
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
@frappe.whitelist()
def create_or_update_cheque_print_format(template_name: str):
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

@@ -84,10 +84,10 @@ class CostCenter(NestedSet):
return frappe.db.get_value("GL Entry", {"cost_center": self.name})
def check_if_child_exists(self):
return frappe.db.sql(
"select name from `tabCost Center` where \
parent_cost_center = %s and docstatus != 2",
self.name,
return frappe.get_all(
"Cost Center",
filters={"parent_cost_center": self.name, "docstatus": ["!=", 2]},
pluck="name",
)
def if_allocation_exists_against_cost_center(self):

View File

@@ -182,7 +182,7 @@ class TestCostCenterAllocation(ERPNextTestSuite):
self.assertTrue(gl_entries)
for gle in gl_entries:
self.assertTrue(gle.cost_center in expected_values)
self.assertIn(gle.cost_center, expected_values)
self.assertEqual(gle.debit, 0)
self.assertEqual(gle.credit, expected_values[gle.cost_center])

View File

@@ -173,3 +173,66 @@ class TestCouponCode(ERPNextTestSuite):
# Clean up
coupon.delete()
def test_validate_coupon_code_rejections(self):
from frappe.utils import add_days, nowdate
from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code
pricing_rule = frappe.db.get_value("Pricing Rule", {"title": "_Test Pricing Rule for _Test Item"})
def make_coupon(name, **kwargs):
frappe.delete_doc_if_exists("Coupon Code", name)
return frappe.get_doc(
{
"doctype": "Coupon Code",
"coupon_name": name,
"coupon_code": name,
"coupon_type": "Promotional",
"pricing_rule": pricing_rule,
**kwargs,
}
).insert(ignore_permissions=True)
with self.subTest("validity not yet started"):
make_coupon("_Test Coupon Future", valid_from=add_days(nowdate(), 5))
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Future")
with self.subTest("validity expired"):
make_coupon("_Test Coupon Expired", valid_upto=add_days(nowdate(), -5))
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Expired")
with self.subTest("maximum use exhausted"):
make_coupon("_Test Coupon Exhausted", maximum_use=2, used=2)
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Exhausted")
with self.subTest("valid coupon passes"):
make_coupon("_Test Coupon Valid", maximum_use=5, used=1, valid_upto=add_days(nowdate(), 5))
validate_coupon_code("_Test Coupon Valid") # no raise
def test_update_coupon_code_count_cancel_and_exhaust(self):
from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count
pricing_rule = frappe.db.get_value("Pricing Rule", {"title": "_Test Pricing Rule for _Test Item"})
frappe.delete_doc_if_exists("Coupon Code", "_Test Coupon Count")
frappe.get_doc(
{
"doctype": "Coupon Code",
"coupon_name": "_Test Coupon Count",
"coupon_code": "_Test Coupon Count",
"coupon_type": "Promotional",
"pricing_rule": pricing_rule,
"maximum_use": 2,
"used": 1,
}
).insert(ignore_permissions=True)
# cancelling a transaction releases one use
update_coupon_code_count("_Test Coupon Count", "cancelled")
self.assertEqual(frappe.db.get_value("Coupon Code", "_Test Coupon Count", "used"), 0)
# using up to the maximum is allowed, beyond it is rejected
update_coupon_code_count("_Test Coupon Count", "used")
update_coupon_code_count("_Test Coupon Count", "used")
self.assertEqual(frappe.db.get_value("Coupon Code", "_Test Coupon Count", "used"), 2)
self.assertRaises(frappe.ValidationError, update_coupon_code_count, "_Test Coupon Count", "used")

View File

@@ -11,22 +11,28 @@ frappe.ui.form.on("Currency Exchange Settings", {
},
callback: function (r) {
if (r && r.message) {
let result = [],
params = {};
if (frm.doc.service_provider == "exchangerate.host") {
let result = ["result"];
let params = {
result = ["result"];
params = {
date: "{transaction_date}",
from: "{from_currency}",
to: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
let result = ["rates", "{to_currency}"];
let params = {
result = ["rates", "{to_currency}"];
params = {
base: "{from_currency}",
symbols: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (frm.doc.service_provider == "frankfurter.dev - v2") {
result = ["rate"];
params = {
date: "{transaction_date}",
};
}
add_param(frm, r.message, params, result);
}
},
});

View File

@@ -78,7 +78,7 @@
"fieldname": "service_provider",
"fieldtype": "Select",
"label": "Service Provider",
"options": "frankfurter.dev\nexchangerate.host\nCustom",
"options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
"reqd": 1
},
{
@@ -101,11 +101,10 @@
"label": "Use HTTP Protocol"
}
],
"hide_toolbar": 0,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:21.075743",
"modified": "2026-06-15 11:25:55.873110",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
@@ -122,24 +121,11 @@
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts User",
"share": 1,
"write": 1
"share": 1
}
],
"row_format": "Dynamic",

View File

@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
disabled: DF.Check
req_params: DF.Table[CurrencyExchangeSettingsDetails]
result_key: DF.Table[CurrencyExchangeSettingsResult]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
url: DF.Data | None
use_http: DF.Check
# end: auto-generated types
@@ -70,6 +70,14 @@ class CurrencyExchangeSettings(Document):
self.append("req_params", {"key": "base", "value": "{from_currency}"})
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
elif self.service_provider == "frankfurter.dev - v2":
self.set("result_key", [])
self.set("req_params", [])
self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
self.append("result_key", {"key": "rate"})
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
def validate_parameters(self):
params = {}
for row in self.req_params:
@@ -82,7 +90,7 @@ class CurrencyExchangeSettings(Document):
try:
response = requests.get(api_url, params=params)
except requests.exceptions.RequestException as e:
frappe.throw("Error: " + str(e))
frappe.throw(_("Error: {0}").format(str(e)))
response.raise_for_status()
value = response.json()
@@ -105,13 +113,20 @@ class CurrencyExchangeSettings(Document):
@frappe.whitelist()
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
if service_provider and service_provider in [
"exchangerate.host",
"frankfurter.dev",
"frankfurter.app",
"frankfurter.dev - v2",
]:
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
api = "api.frankfurter.app/{transaction_date}"
elif service_provider == "frankfurter.dev":
api = "api.frankfurter.dev/v1/{transaction_date}"
elif service_provider == "frankfurter.dev - v2":
api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}"
protocol = "https://"
if use_http:

View File

@@ -60,7 +60,7 @@ frappe.ui.form.on("Dunning", {
if (frm.doc.docstatus === 0) {
frm.add_custom_button(__("Fetch Overdue Payments"), () => {
erpnext.utils.map_current_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning",
source_doctype: "Sales Invoice",
date_field: "due_date",
target: frm,

View File

@@ -85,7 +85,7 @@ class Dunning(AccountsController):
if invoice_currency != self.currency:
frappe.throw(
_(
"The currency of invoice {} ({}) is different from the currency of this dunning ({})."
"The currency of invoice {0} ({1}) is different from the currency of this dunning ({2})."
).format(
frappe.get_desk_link(
"Sales Invoice",
@@ -248,8 +248,7 @@ def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str |
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
if isinstance(doc, str):
doc = json.loads(doc)
doc = frappe.parse_json(doc)
if not language:
language = doc.get("language")

View File

@@ -8,7 +8,7 @@ from frappe.utils import add_days, nowdate, today
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
from erpnext.accounts.doctype.sales_invoice.mapper import (
create_dunning as create_dunning_from_sales_invoice,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
@@ -73,7 +73,7 @@ class TestDunning(ERPNextTestSuite):
dunning = create_dunning_from_sales_invoice(si1.name)
dunning.overdue_payments = []
method = "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning"
method = "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning"
updated_dunning = mapper.map_docs(method, json.dumps([si1.name, si2.name]), dunning)
self.assertEqual(len(updated_dunning.overdue_payments), 2)

View File

@@ -136,7 +136,7 @@ frappe.ui.form.on("Exchange Rate Revaluation Account", {
var get_account_details = function (frm, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn);
if (!frm.doc.company || !frm.doc.posting_date) {
frappe.throw(__("Please select Company and Posting Date to getting entries"));
frappe.throw(__("Please select Company and Posting Date to get entries"));
}
frappe.call({
method: "erpnext.accounts.doctype.exchange_rate_revaluation.exchange_rate_revaluation.get_account_details",

View File

@@ -8,7 +8,7 @@ from frappe import _, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Criterion, Order
from frappe.query_builder.functions import NullIf, Sum
from frappe.query_builder.functions import Max, NullIf, Sum
from frappe.utils import flt, get_link_to_form
import erpnext
@@ -73,7 +73,7 @@ class ExchangeRateRevaluation(Document):
def validate_mandatory(self):
if not (self.company and self.posting_date):
frappe.throw(_("Please select Company and Posting Date to getting entries"))
frappe.throw(_("Please select Company and Posting Date to get entries"))
def before_submit(self):
self.remove_accounts_without_gain_loss()
@@ -188,12 +188,18 @@ class ExchangeRateRevaluation(Document):
accounts = [x[0] for x in res]
if accounts:
having_clause = (qb.Field("balance") != qb.Field("balance_in_account_currency")) & (
(qb.Field("balance_in_account_currency") != 0) | (qb.Field("balance") != 0)
)
gle = qb.DocType("GL Entry")
# balance expressions reused in both SELECT and HAVING; postgres can't reference a
# SELECT alias inside HAVING, so the aggregate expression must be repeated there.
balance = Sum(gle.debit) - Sum(gle.credit)
balance_in_account_currency = Sum(gle.debit_in_account_currency) - Sum(
gle.credit_in_account_currency
)
having_clause = (balance != balance_in_account_currency) & (
(balance_in_account_currency != 0) | (balance != 0)
)
# conditions
conditions = []
conditions.append(gle.account.isin(accounts))
@@ -209,17 +215,15 @@ class ExchangeRateRevaluation(Document):
qb.from_(gle)
.select(
gle.account,
gle.party_type,
gle.party,
gle.account_currency,
(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency)).as_(
"balance_in_account_currency"
),
(Sum(gle.debit) - Sum(gle.credit)).as_("balance"),
(Sum(gle.debit) - Sum(gle.credit) == 0)
^ (Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency) == 0).as_(
"zero_balance"
),
# grouped by NullIf(party_type/party, ""); the bare columns + account_currency are
# constant per group -> Max() keeps the GROUP BY valid on postgres with the same value.
Max(gle.party_type).as_("party_type"),
Max(gle.party).as_("party"),
Max(gle.account_currency).as_("account_currency"),
balance_in_account_currency.as_("balance_in_account_currency"),
balance.as_("balance"),
# zero_balance is recomputed in Python below (after rounding), so the SQL value is
# unused -- dropped (it used MySQL's XOR operator, which postgres lacks).
)
.where(Criterion.all(conditions))
.groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, ""))
@@ -346,12 +350,14 @@ class ExchangeRateRevaluation(Document):
zero_balance_jv = self.make_jv_for_zero_balance()
if zero_balance_jv:
frappe.msgprint(
f"Zero Balance Journal: {get_link_to_form('Journal Entry', zero_balance_jv.name)}"
_("Zero Balance Journal: {0}").format(get_link_to_form("Journal Entry", zero_balance_jv.name))
)
revaluation_jv = self.make_jv_for_revaluation()
if revaluation_jv:
frappe.msgprint(f"Revaluation Journal: {get_link_to_form('Journal Entry', revaluation_jv.name)}")
frappe.msgprint(
_("Revaluation Journal: {0}").format(get_link_to_form("Journal Entry", revaluation_jv.name))
)
return {
"revaluation_jv": revaluation_jv.name if revaluation_jv else None,
@@ -601,7 +607,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
last_exchange_rate = (
qb.from_(gl)
.select((gl.debit - gl.credit) / (gl.debit_in_account_currency - gl.credit_in_account_currency))
.select(
(gl.debit - gl.credit)
/ NullIf(gl.debit_in_account_currency - gl.credit_in_account_currency, 0)
)
.where(
(gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account)
)
@@ -622,6 +631,8 @@ def get_account_details(
party: str | None = None,
rounding_loss_allowance: float = 0.0,
):
frappe.has_permission("Account", doc=account, throw=True)
if not (company and posting_date):
frappe.throw(_("Company and Posting Date is mandatory"))

View File

@@ -15,11 +15,11 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_usd_receivable_account()
self.create_item()
self.create_customer()
self.clear_old_entries()
self.company = "_Test Company"
self.item = "_Test Item"
self.customer = "_Test Customer"
self.cost_center = "Main - _TC"
self.debtors_usd = "_Test Receivable USD - _TC"
self.set_system_and_company_settings()
def set_system_and_company_settings(self):

View File

@@ -12,8 +12,9 @@ from typing import Any, Union
import frappe
from frappe import _
from frappe.database.operator_map import OPERATOR_MAP
from frappe.model import numeric_fieldtypes
from frappe.query_builder import Case
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Cast_, Sum
from frappe.utils import cstr, date_diff, flt, getdate
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
from pypika.terms import Bracket, LiteralValue
@@ -864,8 +865,15 @@ class FilterExpressionParser:
field = getattr(table, field_name, None)
operator_fn = OPERATOR_MAP.get(operator.casefold())
if "like" in operator.casefold() and "%" not in value:
value = f"%{value}%"
if "like" in operator.casefold():
if "%" not in value:
value = f"%{value}%"
# Postgres has no LIKE/ILIKE operator for non-text columns; MariaDB implicitly casts
# the numeric column to text. Cast a numeric/Check Account field to varchar so the
# match runs on both engines and reproduces MariaDB's result.
meta_field = frappe.get_meta("Account").get_field(field_name)
if meta_field and meta_field.fieldtype in numeric_fieldtypes:
field = Cast_(field, "varchar")
return operator_fn(field, value)
@@ -1024,8 +1032,7 @@ class FormulaFieldUpdater:
def get_filtered_accounts(company: str, account_rows: str | list):
frappe.has_permission("Financial Report Template", ptype="read", throw=True)
if isinstance(account_rows, str):
account_rows = json.loads(account_rows, object_hook=frappe._dict)
account_rows = [frappe._dict(row) for row in frappe.parse_json(account_rows)]
return DataCollector.get_filtered_accounts(company, account_rows)

View File

@@ -361,7 +361,7 @@ class CalculationFormulaValidator(Validator):
"sqrt": lambda x: x**0.5,
"pow": pow,
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
"floor": lambda x: int(x),
"floor": int,
}
)

View File

@@ -72,10 +72,8 @@ class FiscalYear(Document):
if existing_fiscal_years:
for existing in existing_fiscal_years:
company_for_existing = frappe.db.sql_list(
"""select company from `tabFiscal Year Company`
where parent=%s""",
existing.name,
company_for_existing = frappe.get_all(
"Fiscal Year Company", filters={"parent": existing.name}, pluck="company"
)
overlap = False

View File

@@ -7,6 +7,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.model.naming import set_name_from_naming_options
from frappe.query_builder.functions import Sum
from frappe.utils import create_batch, flt, fmt_money, now
import erpnext
@@ -331,10 +332,12 @@ def validate_balance_type(account, adv_adj=False):
if not adv_adj and account:
balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
if balance_must_be:
balance = frappe.db.sql(
"""select sum(debit) - sum(credit)
from `tabGL Entry` where is_cancelled = 0 and account = %s""",
account,
gle = frappe.qb.DocType("GL Entry")
balance = (
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where((gle.is_cancelled == 0) & (gle.account == account))
.run()
)[0][0]
if (balance_must_be == "Debit" and flt(balance) < 0) or (
@@ -348,44 +351,48 @@ def validate_balance_type(account, adv_adj=False):
def update_outstanding_amt(
account, party_type, party, against_voucher_type, against_voucher, on_cancel=False
):
gle = frappe.qb.DocType("GL Entry")
conditions = (
(gle.against_voucher_type == against_voucher_type)
& (gle.against_voucher == against_voucher)
& (gle.voucher_type != "Invoice Discounting")
)
if party_type and party:
party_condition = " and party_type={} and party={}".format(
frappe.db.escape(party_type), frappe.db.escape(party)
)
else:
party_condition = ""
conditions &= (gle.party_type == party_type) & (gle.party == party)
if against_voucher_type == "Sales Invoice":
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
account_condition = f"and account in ({frappe.db.escape(account)}, {frappe.db.escape(party_account)})"
conditions &= gle.account.isin([account, party_account])
else:
account_condition = f" and account = {frappe.db.escape(account)}"
conditions &= gle.account == account
# get final outstanding amt
bal = flt(
frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where against_voucher_type=%s and against_voucher=%s
and voucher_type != 'Invoice Discounting'
{party_condition} {account_condition}""",
(against_voucher_type, against_voucher),
)[0][0]
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(conditions)
.run()[0][0]
or 0.0
)
if against_voucher_type == "Purchase Invoice":
bal = -bal
elif against_voucher_type == "Journal Entry":
je_conditions = (
(gle.voucher_type == "Journal Entry")
& (gle.voucher_no == against_voucher)
& (gle.account == account)
& (gle.against_voucher.isnull() | (gle.against_voucher == ""))
)
if party_type and party:
je_conditions &= (gle.party_type == party_type) & (gle.party == party)
against_voucher_amount = flt(
frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry` where voucher_type = 'Journal Entry' and voucher_no = %s
and account = %s and (against_voucher is null or against_voucher='') {party_condition}""",
(against_voucher, account),
)[0][0]
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(je_conditions)
.run()[0][0]
)
if not against_voucher_amount:
@@ -480,10 +487,14 @@ def rename_temporarily_named_docs(doctype):
oldname = doc.name
set_name_from_naming_options(autoname, doc)
newname = doc.name
frappe.db.sql(
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
(newname, now(), oldname),
)
dt = frappe.qb.DocType(doctype)
(
frappe.qb.update(dt)
.set(dt.name, newname)
.set(dt.to_rename, 0)
.set(dt.modified, now())
.where(dt.name == oldname)
).run()
for hook_type in ("on_gle_rename", "on_sle_rename"):
for hook in frappe.get_hooks(hook_type):

View File

@@ -26,12 +26,17 @@ class TestGLEntry(ERPNextTestSuite):
jv.flags.ignore_validate = True
jv.submit()
round_off_entry = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_no = %s
and account='_Test Write Off - _TC' and cost_center='_Test Cost Center - _TC'
and debit = 0 and credit = '.01'""",
jv.name,
round_off_entry = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Journal Entry",
"voucher_no": jv.name,
"account": "_Test Write Off - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 0,
"credit": 0.01,
},
pluck="name",
)
self.assertTrue(round_off_entry)
@@ -55,8 +60,9 @@ class TestGLEntry(ERPNextTestSuite):
)
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
old_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
series = frappe.qb.DocType("Series")
old_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
)[0][0]
rename_gle_sle_docs()
@@ -73,8 +79,8 @@ class TestGLEntry(ERPNextTestSuite):
all(new.name != old.name for new, old in zip(gl_entries, new_gl_entries, strict=False))
)
new_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
new_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
)[0][0]
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)

View File

@@ -317,58 +317,50 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
def get_invoices(filters: str):
filters = frappe._dict(json.loads(filters))
cond = []
if filters.customer:
cond.append("customer=%(customer)s")
if filters.from_date:
cond.append("posting_date >= %(from_date)s")
if filters.to_date:
cond.append("posting_date <= %(to_date)s")
if filters.min_amount:
cond.append("base_grand_total >= %(min_amount)s")
if filters.max_amount:
cond.append("base_grand_total <= %(max_amount)s")
def get_invoices(filters: str | dict):
filters = frappe._dict(frappe.parse_json(filters))
si = frappe.qb.DocType("Sales Invoice")
di = frappe.qb.DocType("Discounted Invoice")
where_condition = ""
if cond:
where_condition += " and " + " and ".join(cond)
discounted = frappe.qb.from_(di).select(di.sales_invoice).where(di.docstatus == 1)
return frappe.db.sql(
"""
select
name as sales_invoice,
customer,
posting_date,
outstanding_amount,
debit_to
from `tabSales Invoice` si
where
docstatus = 1
and outstanding_amount > 0
%s
and not exists(select di.name from `tabDiscounted Invoice` di
where di.docstatus=1 and di.sales_invoice=si.name)
"""
% where_condition,
filters,
as_dict=1,
query = (
frappe.qb.from_(si)
.select(
si.name.as_("sales_invoice"),
si.customer,
si.posting_date,
si.outstanding_amount,
si.debit_to,
)
.where((si.docstatus == 1) & (si.outstanding_amount > 0) & si.name.notin(discounted))
)
if filters.customer:
query = query.where(si.customer == filters.customer)
if filters.from_date:
query = query.where(si.posting_date >= filters.from_date)
if filters.to_date:
query = query.where(si.posting_date <= filters.to_date)
if filters.min_amount:
query = query.where(si.base_grand_total >= filters.min_amount)
if filters.max_amount:
query = query.where(si.base_grand_total <= filters.max_amount)
return query.run(as_dict=1)
def get_party_account_based_on_invoice_discounting(sales_invoice):
party_account = None
invoice_discounting = frappe.db.sql(
"""
select par.accounts_receivable_discounted, par.accounts_receivable_unpaid, par.status
from `tabInvoice Discounting` par, `tabDiscounted Invoice` ch
where par.name=ch.parent
and par.docstatus=1
and ch.sales_invoice = %s
""",
(sales_invoice),
as_dict=1,
par = frappe.qb.DocType("Invoice Discounting")
ch = frappe.qb.DocType("Discounted Invoice")
invoice_discounting = (
frappe.qb.from_(par)
.inner_join(ch)
.on(par.name == ch.parent)
.select(par.accounts_receivable_discounted, par.accounts_receivable_unpaid, par.status)
.where((par.docstatus == 1) & (ch.sales_invoice == sales_invoice))
.run(as_dict=1)
)
if invoice_discounting:
if invoice_discounting[0].status == "Disbursed":

View File

@@ -5,7 +5,7 @@ import frappe
from frappe.utils import add_days, flt, nowdate
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry_against_invoice
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
from erpnext.tests.utils import ERPNextTestSuite

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Document builders that map a source document to a Journal Entry or to a
Payment Entry raised against it."""
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt, get_link_to_form, nowdate
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
get_party_account_based_on_invoice_discounting,
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import get_account_currency
@frappe.whitelist()
def get_payment_entry_against_order(
dt: str,
dn: str,
amount: float | None = None,
debit_in_account_currency: str | float | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
) -> dict | Document:
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
ref_doc = frappe.get_doc(dt, dn)
if flt(ref_doc.per_billed, 2) > 0:
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
if dt == "Sales Order":
party_type = "Customer"
amount_field_party = "credit_in_account_currency"
amount_field_bank = "debit_in_account_currency"
else:
party_type = "Supplier"
amount_field_party = "debit_in_account_currency"
amount_field_bank = "credit_in_account_currency"
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
party_account_currency = get_account_currency(party_account)
if not amount:
if party_account_currency == ref_doc.company_currency:
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
else:
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
return get_payment_entry(
ref_doc,
{
"party_type": party_type,
"party_account": party_account,
"party_account_currency": party_account_currency,
"amount_field_party": amount_field_party,
"amount_field_bank": amount_field_bank,
"amount": amount,
"debit_in_account_currency": debit_in_account_currency,
"remarks": f"Advance Payment received against {dt} {dn}",
"is_advance": "Yes",
"bank_account": bank_account,
"journal_entry": journal_entry,
},
)
@frappe.whitelist()
def get_payment_entry_against_invoice(
dt: str,
dn: str,
amount: float | None = None,
debit_in_account_currency: str | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
) -> dict | Document:
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
ref_doc = frappe.get_doc(dt, dn)
if dt == "Sales Invoice":
party_type = "Customer"
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
else:
party_type = "Supplier"
party_account = ref_doc.credit_to
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
):
amount_field_party = "credit_in_account_currency"
amount_field_bank = "debit_in_account_currency"
else:
amount_field_party = "debit_in_account_currency"
amount_field_bank = "credit_in_account_currency"
return get_payment_entry(
ref_doc,
{
"party_type": party_type,
"party_account": party_account,
"party_account_currency": ref_doc.party_account_currency,
"amount_field_party": amount_field_party,
"amount_field_bank": amount_field_bank,
"amount": amount if amount else abs(ref_doc.outstanding_amount),
"debit_in_account_currency": debit_in_account_currency,
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
"is_advance": "No",
"bank_account": bank_account,
"journal_entry": journal_entry,
},
)
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
dict (for client calls).
"""
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
"Company", ref_doc.company, "cost_center"
)
exchange_rate = _reference_exchange_rate(ref_doc, args)
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
if party_row.account_currency != ref_doc.company_currency or (
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
):
je.multi_currency = 1
je.set_amounts_in_company_currency()
je.set_total_debit_credit()
return je if args.get("journal_entry") else je.as_dict()
def _reference_exchange_rate(ref_doc, args: dict) -> float:
"""Exchange rate of the party account on the reference document's posting date."""
if not args.get("party_account"):
return 1
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
return get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
args.get("party_account"),
args.get("party_account_currency"),
ref_doc.company,
ref_doc.doctype,
ref_doc.name,
)
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the party (debtor/creditor) row that records the advance/payment."""
return je.append(
"accounts",
{
"account": args.get("party_account"),
"party_type": args.get("party_type"),
"party": ref_doc.get(args.get("party_type").lower()),
"cost_center": cost_center,
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
"account_currency": args.get("party_account_currency")
or get_account_currency(args.get("party_account")),
"exchange_rate": exchange_rate,
args.get("amount_field_party"): args.get("amount"),
"is_advance": args.get("is_advance"),
"reference_type": ref_doc.doctype,
"reference_name": ref_doc.name,
},
)
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
bank_row = je.append("accounts")
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
if bank_account:
bank_row.update(bank_account)
# posting date assumed to be the reference document's posting/transaction date
bank_row.exchange_rate = get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
bank_account["account"],
bank_account["account_currency"],
ref_doc.company,
)
bank_row.cost_center = cost_center
amount = args.get("debit_in_account_currency") or args.get("amount")
if bank_row.account_currency == args.get("party_account_currency"):
bank_row.set(args.get("amount_field_bank"), amount)
else:
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
return bank_row
@frappe.whitelist()
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = voucher_type
journal_entry.company = company
journal_entry.posting_date = nowdate()
journal_entry.inter_company_journal_entry_reference = name
return journal_entry.as_dict()
@frappe.whitelist()
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
get_link_to_form("Journal Entry", existing_reverse)
)
)
from frappe.model.mapper import get_mapped_doc
def post_process(source, target) -> None:
target.reversal_of = source.name
doclist = get_mapped_doc(
"Journal Entry",
source_name,
{
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
"Journal Entry Account": {
"doctype": "Journal Entry Account",
"field_map": {
"account_currency": "account_currency",
"exchange_rate": "exchange_rate",
"debit_in_account_currency": "credit_in_account_currency",
"debit": "credit",
"credit_in_account_currency": "debit_in_account_currency",
"credit": "debit",
"reference_type": "reference_type",
"reference_name": "reference_name",
},
},
},
target_doc,
post_process,
)
return doclist

View File

@@ -0,0 +1,200 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
get_depr_schedule,
)
class AssetService:
"""Keeps Assets in sync with the Journal Entries that depreciate, dispose or
adjust them.
On submit of a Depreciation Entry it reduces the asset value and links the
depreciation schedule; on submit of an Asset Disposal it marks the asset
disposed. On cancel it reverses those links. It also guards cancellation of
Journal Entries tied to asset scrapping or value adjustments.
"""
def __init__(self, doc) -> None:
self.doc = doc
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
for d in self.doc.get("accounts"):
if d.account_type == "Depreciation":
if self.doc.voucher_type != "Depreciation Entry":
frappe.throw(
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
)
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
def has_asset_adjustment_entry(self) -> None:
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
if self.doc.flags.get("via_asset_value_adjustment"):
return
asset_value_adjustment = frappe.db.get_value(
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name"
)
if asset_value_adjustment:
frappe.throw(
_(
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
)
def update_asset_value(self) -> None:
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
self.update_asset_on_depreciation()
self.update_asset_on_disposal()
def update_asset_on_depreciation(self) -> None:
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
if self.doc.voucher_type != "Depreciation Entry":
return
for d in self.doc.get("accounts"):
if (
d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.debit
):
asset = frappe.get_cached_doc("Asset", d.reference_name)
if asset.calculate_depreciation:
self.update_journal_entry_link_on_depr_schedule(asset, d)
self.update_value_after_depreciation(asset, d.debit)
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
"""Subtract the depreciation amount from the asset's relevant finance book."""
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
if fb_row.finance_book == self.doc.finance_book:
fb_idx = fb_row.idx
break
fb_row = asset.get("finance_books")[fb_idx - 1]
fb_row.value_after_depreciation -= depr_amount
frappe.db.set_value(
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
)
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
for d in depr_schedule or []:
if (
d.schedule_date == self.doc.posting_date
and not d.journal_entry
and d.depreciation_amount == flt(je_row.debit)
):
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
def update_asset_on_disposal(self) -> None:
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
if self.doc.voucher_type == "Asset Disposal":
disposed_assets = []
for d in self.doc.get("accounts"):
if (
d.reference_type == "Asset"
and d.reference_name
and d.reference_name not in disposed_assets
):
frappe.db.set_value(
"Asset",
d.reference_name,
{
"disposal_date": self.doc.posting_date,
"journal_entry_for_scrap": self.doc.name,
},
)
asset_doc = frappe.get_doc("Asset", d.reference_name)
asset_doc.set_status()
disposed_assets.append(d.reference_name)
def unlink_asset_reference(self) -> None:
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
for d in self.doc.get("accounts"):
if self._is_depreciation_asset_row(d):
self._reverse_asset_depreciation(d)
elif (
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
):
self._block_scrap_journal_cancel(d)
def _is_depreciation_asset_row(self, d) -> bool:
return bool(
self.doc.voucher_type == "Depreciation Entry"
and d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.debit
)
def _reverse_asset_depreciation(self, d) -> None:
"""Add the depreciation amount back to the asset and unlink its schedule row."""
asset = frappe.get_doc("Asset", d.reference_name)
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
self._restore_finance_book_value(asset, d.debit)
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
"""Unlink this entry from the depreciation schedule and credit back its finance book.
Returns True if a matching scheduled depreciation was found.
"""
for fb_row in asset.get("finance_books"):
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
for s in depr_schedule or []:
if s.journal_entry == self.doc.name:
s.db_set("journal_entry", None)
fb_row.value_after_depreciation += debit
fb_row.db_update()
return True
return False
def _restore_finance_book_value(self, asset, debit: float) -> None:
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
if fb_row.finance_book == self.doc.finance_book:
fb_idx = fb_row.idx
break
fb_row = asset.get("finance_books")[fb_idx - 1]
fb_row.value_after_depreciation += debit
fb_row.db_update()
def _block_scrap_journal_cancel(self, d) -> None:
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap")
if journal_entry_for_scrap == self.doc.name:
frappe.throw(
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
)
def unlink_asset_adjustment_entry(self) -> None:
"""Detach this entry from any Asset Value Adjustment that referenced it."""
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
(
frappe.qb.update(AssetValueAdjustment)
.set(AssetValueAdjustment.journal_entry, None)
.where(AssetValueAdjustment.journal_entry == self.doc.name)
).run()

View File

@@ -0,0 +1,105 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt
import erpnext
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
from erpnext.accounts.utils import get_advance_payment_doctypes
class JournalEntryGLComposer(BaseGLComposer):
"""Assembles the GL entries for a Journal Entry.
A Journal Entry already carries its ledger rows in the ``accounts`` child
table, so composing is a straight projection of those rows into GL dicts
via ``self.get_gl_dict``. The transaction currency/rate are resolved
from the first foreign-currency row (mirroring the former build_gl_map).
"""
def compose(self) -> list:
"""Project the Journal Entry's non-zero account rows into GL dicts."""
self._set_transaction_currency()
advance_doctypes = get_advance_payment_doctypes()
gl_map = []
for d in self.doc.get("accounts"):
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
return gl_map
def _set_transaction_currency(self) -> None:
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
doc = self.doc
doc.transaction_currency = erpnext.get_company_currency(doc.company)
doc.transaction_exchange_rate = 1
if not doc.multi_currency:
return
for row in doc.get("accounts"):
if row.account_currency != doc.transaction_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
def _gl_row(self, d, advance_doctypes: list) -> dict:
"""Build the GL dict for a single account row."""
doc = self.doc
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
return row

View File

@@ -0,0 +1,199 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _, scrub
from frappe.utils import cstr, flt, fmt_money
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
get_party_account_based_on_invoice_discounting,
)
from erpnext.accounts.utils import get_account_currency
REFERENCE_PARTY_ACCOUNT_FIELDS = {
"Sales Invoice": ["Customer", "Debit To"],
"Purchase Invoice": ["Supplier", "Credit To"],
"Sales Order": ["Customer"],
"Purchase Order": ["Supplier"],
}
class JournalEntryReferenceValidator:
"""Validates Journal Entry account rows against their referenced documents.
For each row that links a Sales/Purchase Invoice or Order, this checks the
debit/credit direction, party and account match, and aggregates per-reference
totals (held on the document as ``reference_totals``/``reference_types``/
``reference_accounts``) which are then validated against the referenced
orders and invoices.
"""
def __init__(self, doc) -> None:
self.doc = doc
def validate(self) -> None:
"""Validate every reference-bearing row, then the referenced orders and invoices."""
self.doc.reference_totals = {}
self.doc.reference_types = {}
self.doc.reference_accounts = {}
for row in self.doc.get("accounts"):
self._normalize_reference_fields(row)
if not self._has_party_reference(row):
continue
self._validate_order_direction(row)
self._register_reference(row)
self._validate_reference_party_and_account(row)
self._validate_orders()
self._validate_invoices()
def _normalize_reference_fields(self, row) -> None:
if not row.reference_type:
row.reference_name = None
if not row.reference_name:
row.reference_type = None
def _has_party_reference(self, row) -> bool:
return bool(
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
)
def _reference_amount_field(self, row) -> str:
if row.reference_type in ("Sales Order", "Sales Invoice"):
return "credit_in_account_currency"
return "debit_in_account_currency"
def _validate_order_direction(self, row) -> None:
"""An order can only be linked on the side that records an advance."""
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
frappe.throw(
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
)
if row.reference_type == "Purchase Order" and flt(row.credit) > 0:
frappe.throw(
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
)
def _register_reference(self, row) -> None:
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
if row.reference_name not in self.doc.reference_totals:
self.doc.reference_totals[row.reference_name] = 0.0
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
self.doc.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row)))
self.doc.reference_types[row.reference_name] = row.reference_type
self.doc.reference_accounts[row.reference_name] = row.account
def _validate_reference_party_and_account(self, row) -> None:
"""Reject a missing reference, then check party/account against the linked document."""
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
against_voucher = frappe.db.get_value(
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
)
if not against_voucher:
frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name))
if row.reference_type in ("Sales Invoice", "Purchase Invoice"):
self._validate_invoice_party_and_account(row, against_voucher, party_fields)
elif row.reference_type in ("Sales Order", "Purchase Order"):
self._validate_order_party(row, against_voucher)
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
if self.doc.voucher_type == "Exchange Gain Or Loss":
return
if against_party != cstr(row.party) or party_account != row.account:
frappe.throw(
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name
)
)
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
"""Expected (party_account, party) for an invoice row, honouring deferred booking
and invoice-discounting accounts."""
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
debit_or_credit = "Debit" if row.debit else "Credit"
party_account = get_deferred_booking_accounts(
row.reference_type, row.reference_detail_no, debit_or_credit
)
return party_account, ""
if row.reference_type == "Sales Invoice":
party_account = (
get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1]
)
else:
party_account = against_voucher[1]
return party_account, against_voucher[0]
def _validate_order_party(self, row, against_voucher) -> None:
if against_voucher != row.party:
frappe.throw(
_("Row {0}: {1} {2} does not match with {3}").format(
row.idx, row.party_type, row.party, row.reference_type
)
)
def _validate_orders(self) -> None:
"""Validate totals, closed and docstatus for referenced orders."""
for reference_name, total in self.doc.reference_totals.items():
reference_type = self.doc.reference_types[reference_name]
account = self.doc.reference_accounts[reference_name]
if reference_type not in ("Sales Order", "Purchase Order"):
continue
order = frappe.get_doc(reference_type, reference_name)
self._validate_order_status(order, reference_type, reference_name)
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
def _validate_order_status(self, order, reference_type, reference_name) -> None:
if order.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
if flt(order.per_billed) >= 100:
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
if cstr(order.status) == "Closed":
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
"""The advance paid against an order cannot exceed its grand total."""
account_currency = get_account_currency(account)
if account_currency == self.doc.company_currency:
voucher_total = order.base_grand_total
field = "base_grand_total"
else:
voucher_total = order.grand_total
field = "grand_total"
if flt(voucher_total) < (flt(order.advance_paid) + total):
formatted_voucher_total = fmt_money(
voucher_total, order.precision(field), currency=account_currency
)
frappe.throw(
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
reference_type, reference_name, formatted_voucher_total
)
)
def _validate_invoices(self) -> None:
"""Validate totals and docstatus for referenced invoices."""
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
return
for reference_name, total in self.doc.reference_totals.items():
reference_type = self.doc.reference_types[reference_name]
if reference_type not in ("Sales Invoice", "Purchase Invoice"):
continue
invoice = frappe.get_doc(reference_type, reference_name)
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
"""Payment booked against an invoice cannot exceed its outstanding amount."""
if invoice.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
precision = invoice.precision("outstanding_amount")
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
frappe.throw(
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
reference_type, reference_name, invoice.outstanding_amount
)
)

View File

@@ -43,18 +43,18 @@ class TestJournalEntry(ERPNextTestSuite):
if test_voucher.doctype == "Journal Entry":
self.assertTrue(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where account = %s and docstatus = 1 and parent = %s""",
("Debtors - _TC", test_voucher.name),
frappe.get_all(
"Journal Entry Account",
filters={"account": "Debtors - _TC", "docstatus": 1, "parent": test_voucher.name},
pluck="name",
)
)
self.assertFalse(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type = %s and reference_name = %s""",
(test_voucher.doctype, test_voucher.name),
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": test_voucher.doctype, "reference_name": test_voucher.name},
pluck="name",
)
)
@@ -69,10 +69,14 @@ class TestJournalEntry(ERPNextTestSuite):
submitted_voucher = frappe.get_doc(test_voucher.doctype, test_voucher.name)
self.assertTrue(
frappe.db.sql(
f"""select name from `tabJournal Entry Account`
where reference_type = %s and reference_name = %s and {dr_or_cr}=400""",
(submitted_voucher.doctype, submitted_voucher.name),
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": submitted_voucher.doctype,
"reference_name": submitted_voucher.name,
dr_or_cr: 400,
},
pluck="name",
)
)
@@ -82,24 +86,20 @@ class TestJournalEntry(ERPNextTestSuite):
def advance_paid_testcase(self, base_jv, test_voucher, dr_or_cr):
# Test advance paid field
advance_paid = frappe.db.sql(
"""select advance_paid from `tab{}`
where name={}""".format(test_voucher.doctype, "%s"),
(test_voucher.name),
)
advance_paid = frappe.db.get_value(test_voucher.doctype, test_voucher.name, "advance_paid")
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
self.assertTrue(flt(advance_paid[0][0]) == flt(payment_against_order))
self.assertEqual(flt(advance_paid), flt(payment_against_order))
def cancel_against_voucher_testcase(self, test_voucher):
if test_voucher.doctype == "Journal Entry":
# if test_voucher is a Journal Entry, test cancellation of test_voucher
test_voucher.cancel()
self.assertFalse(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type='Journal Entry' and reference_name=%s""",
test_voucher.name,
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Journal Entry", "reference_name": test_voucher.name},
pluck="name",
)
)
@@ -169,8 +169,11 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency",
"credit",
"credit_in_account_currency",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
]
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
self.expected_gle = [
{
"account": "_Test Bank - _TC",
@@ -179,6 +182,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 0,
"credit": 5000,
"credit_in_account_currency": 5000,
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
{
"account": "_Test Bank USD - _TC",
@@ -187,6 +192,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 100,
"credit": 0,
"credit_in_account_currency": 0,
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
]
@@ -195,16 +202,62 @@ class TestJournalEntry(ERPNextTestSuite):
# cancel
jv.cancel()
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
jv.name,
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": jv.name},
pluck="name",
)
self.assertFalse(gle)
def test_multi_currency_transaction_currency_on_foreign_debit(self):
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
Transaction currency is USD (the first foreign row); the INR debit row must be
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
"""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.multi_currency = 1
jv.append(
"accounts",
{
"account": "_Test Bank USD - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 100,
"exchange_rate": 50,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": 5000,
"exchange_rate": 1,
},
)
jv.submit()
self.voucher_no = jv.name
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
self.expected_gle = [
{
"account": "_Test Bank - _TC",
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
{
"account": "_Test Bank USD - _TC",
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
]
self.check_gl_entries()
def test_reverse_journal_entry(self):
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False)
@@ -473,9 +526,16 @@ class TestJournalEntry(ERPNextTestSuite):
gl_entries = query.run(as_dict=True)
for i in range(len(self.expected_gle)):
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
# sort both sides identically before the positional comparison.
def _key(row):
return tuple(str(row[f]) for f in self.fields)
gl_entries = sorted(gl_entries, key=_key)
expected_gle = sorted(self.expected_gle, key=_key)
for i in range(len(expected_gle)):
for field in self.fields:
self.assertEqual(self.expected_gle[i][field], gl_entries[i][field])
self.assertEqual(expected_gle[i][field], gl_entries[i][field])
def test_negative_debit_and_credit_with_same_account_head(self):
from erpnext.accounts.general_ledger import process_gl_map
@@ -609,6 +669,197 @@ class TestJournalEntry(ERPNextTestSuite):
jv.save()
self.assertRaises(frappe.ValidationError, jv.submit)
def test_validate_reference_doc_debit_against_sales_order_throws(self):
"""Characterize: a debit entry linked to a Sales Order is rejected."""
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
jv = make_journal_entry("Debtors - _TC", "_Test Cash - _TC", 100, save=False)
jv.accounts[0].party_type = "Customer"
jv.accounts[0].party = "_Test Customer"
jv.accounts[0].reference_type = "Sales Order"
jv.accounts[0].reference_name = sales_order.name
self.assertRaisesRegex(frappe.ValidationError, "Debit entry can not be linked", jv.insert)
def test_validate_reference_doc_credit_against_purchase_order_throws(self):
"""Characterize: a credit entry linked to a Purchase Order is rejected."""
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
purchase_order = create_purchase_order()
jv = make_journal_entry("_Test Cash - _TC", "Creditors - _TC", 100, save=False)
jv.accounts[1].party_type = "Supplier"
jv.accounts[1].party = "_Test Supplier"
jv.accounts[1].reference_type = "Purchase Order"
jv.accounts[1].reference_name = purchase_order.name
self.assertRaisesRegex(frappe.ValidationError, "Credit entry can not be linked", jv.insert)
def test_validate_reference_doc_nonexistent_reference_rejected(self):
"""Characterize: a JE referencing a non-existent invoice is rejected by link validation.
Note: the controller's own "Invalid reference" branch is unreachable in normal flow
because Frappe link validation rejects the missing reference before validate_reference_doc.
"""
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = "NON-EXISTENT-SI"
self.assertRaises(frappe.LinkValidationError, jv.insert)
def test_validate_reference_doc_invoice_party_mismatch_throws(self):
"""Characterize: an invoice reference whose party differs from the row party is rejected."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
other_customer = make_customer("_Test JE Mismatch Customer")
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = other_customer
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
self.assertRaisesRegex(frappe.ValidationError, "Party / Account does not match", jv.insert)
def test_validate_reference_doc_order_party_mismatch_throws(self):
"""Characterize: a Sales Order reference whose party differs from the row party is rejected."""
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
other_customer = make_customer("_Test JE Mismatch Customer")
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = other_customer
jv.accounts[1].is_advance = "Yes"
jv.accounts[1].reference_type = "Sales Order"
jv.accounts[1].reference_name = sales_order.name
self.assertRaisesRegex(frappe.ValidationError, "does not match", jv.insert)
def test_validate_reference_doc_populates_reference_side_effects(self):
"""Characterize: a valid invoice reference populates reference_totals/types/accounts."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
jv.insert()
self.assertEqual(jv.reference_totals[invoice.name], 100.0)
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def test_get_balance_places_difference_on_blank_row(self):
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"debit_in_account_currency": 100,
"debit": 100,
"exchange_rate": 1,
},
)
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1}) # amountless row
jv.set_total_debit_credit()
self.assertEqual(jv.difference, 100)
jv.get_balance()
blank_row = jv.accounts[1]
self.assertEqual(blank_row.credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
def test_get_balance_recomputes_difference_ignoring_client_value(self):
"""get_balance computes its own difference instead of trusting a stale client-sent value."""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"debit_in_account_currency": 100,
"debit": 100,
"exchange_rate": 1,
},
)
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1})
# a stale/incorrect value as the client might send; get_balance must not rely on it
jv.difference = 0
jv.get_balance()
self.assertEqual(jv.accounts[1].credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
self.assertEqual(jv.difference, 0)
def test_get_outstanding_invoices_builds_write_off_rows(self):
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.voucher_type = "Write Off Entry"
jv.write_off_based_on = "Accounts Receivable"
jv.write_off_amount = 1000
jv.get_outstanding_invoices()
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
self.assertTrue(invoice_rows)
self.assertEqual(invoice_rows[0].party_type, "Customer")
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
def test_unlink_advance_entry_reference_on_cancel(self):
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
advance_row = jv.accounts[1]
advance_row.party_type = "Customer"
advance_row.party = "_Test Customer"
advance_row.is_advance = "Yes"
advance_row.reference_type = "Sales Invoice"
advance_row.reference_name = invoice.name
jv.submit()
jv.cancel()
jv.reload()
self.assertFalse(jv.accounts[1].reference_type)
self.assertFalse(jv.accounts[1].reference_name)
def test_get_payment_entry_against_order_builds_advance_je(self):
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
self.assertEqual(je.voucher_type, "Bank Entry")
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
self.assertTrue(party_rows)
self.assertEqual(party_rows[0].reference_type, "Sales Order")
self.assertEqual(party_rows[0].reference_name, sales_order.name)
self.assertEqual(party_rows[0].is_advance, "Yes")
def test_make_inter_company_journal_entry_builds_linked_draft(self):
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
result = make_inter_company_journal_entry(
source.name, "Inter Company Journal Entry", "_Test Company 1"
)
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
self.assertEqual(result.get("company"), "_Test Company 1")
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
def make_journal_entry(
account1,

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