Compare commits

..

277 Commits

Author SHA1 Message Date
Frappe PR Bot
68ea583a1f chore(release): Bumped to Version 16.31.0
# [16.31.0](https://github.com/frappe/erpnext/compare/v16.30.0...v16.31.0) (2026-08-04)

### Bug Fixes

* **accounts receivable:** made territory field multi select (backport [#57322](https://github.com/frappe/erpnext/issues/57322)) ([#57346](https://github.com/frappe/erpnext/issues/57346)) ([9b452f1](9b452f12b7))
* **accounts:** fetch deferred invoice docs on non-empty `sales_docs` or `purchase_docs` in repost accounting ledger ([#57753](https://github.com/frappe/erpnext/issues/57753)) ([6153202](6153202231))
* **accounts:** skip party dashboard without invoice permission ([ee6955d](ee6955d56c))
* **accounts:** update AU standard chart of accounts (backport [#57145](https://github.com/frappe/erpnext/issues/57145)) ([#57608](https://github.com/frappe/erpnext/issues/57608)) ([1ea1ce1](1ea1ce15d0))
* allow custom remark on reversal journal entry ([b4dfca9](b4dfca9ef1))
* apply the supplier selection to this branch's own mapper module ([e5f8d0c](e5f8d0c84b))
* **assets:** add permission checks on whitelisted methods on `asset_capitalization` ([99f249b](99f249b1a0))
* **banking:** fetch company list from DB instead of boot (backport [#57731](https://github.com/frappe/erpnext/issues/57731)) ([#57739](https://github.com/frappe/erpnext/issues/57739)) ([ca60653](ca6065398c))
* do not fetch a random inventory account when multiple inventory accounts exist (backport [#57626](https://github.com/frappe/erpnext/issues/57626)) ([#57632](https://github.com/frappe/erpnext/issues/57632)) ([34cbd3c](34cbd3c8d5))
* drop row prefix in reserve stock message when row is unknown ([684ae4d](684ae4d762))
* escape data in multiple templates (backport [#57742](https://github.com/frappe/erpnext/issues/57742)) ([#57770](https://github.com/frappe/erpnext/issues/57770)) ([824ae57](824ae57e44))
* escape item code and UOM in the supplier dialog errors ([06a753f](06a753faf3))
* exclude transferred and consumed qty from dashboard reserved stock ([46317b0](46317b063a))
* filter Accounts Receivable by invoice sales partner (backport [#57628](https://github.com/frappe/erpnext/issues/57628)) ([#57648](https://github.com/frappe/erpnext/issues/57648)) ([0e46937](0e46937f60))
* guard against None row in get_stock_balance_for (backport [#57567](https://github.com/frappe/erpnext/issues/57567)) ([283ee6e](283ee6e07b))
* guard scio row lookup in stock entry items_add ([fb6c87d](fb6c87dd0c))
* **italy:** skip e-invoicing for opening invoices ([5e584d1](5e584d1cfb))
* **item_variant:** added permission checks on `enqueue_multiple_variant_creation` ([c7cf9d8](c7cf9d868b))
* keep the bulk supplier field to half the supplier selection dialog ([2c9db13](2c9db13041))
* label the items table in the supplier selection dialog ([187840b](187840b559))
* let Purchase Receipt cancel defer to Frappe's linked-document check (backport [#57592](https://github.com/frappe/erpnext/issues/57592)) ([#57597](https://github.com/frappe/erpnext/issues/57597)) ([16be0f0](16be0f0944))
* link the item and spell out the unit in the supplier dialog errors ([810b9ae](810b9ae28f))
* list the Purchase Orders created per supplier instead of opening one ([d2fe4b6](d2fe4b623c))
* **manufacturing:** reach the whole configurator from tree toolbar actions ([281e92f](281e92fb6e)), closes [#57773](https://github.com/frappe/erpnext/issues/57773)
* open the Purchase Order when the supplier selection creates only one ([e71cef0](e71cef02b3))
* **opportunity:** add validation for positive item quantities ([a9f969e](a9f969e942))
* **payment reconciliation:** correct supplier gain/loss posting ([dc907ad](dc907add40))
* **payment_request:** added permission checks on `resend_payment_email` ([fcbbb25](fcbbb251cf))
* **plant_floor:** add missing perm check on `get_stock_summary` (backport [#57667](https://github.com/frappe/erpnext/issues/57667)) ([#57670](https://github.com/frappe/erpnext/issues/57670)) ([7bc0475](7bc04752fc))
* **pos:** don't double-escape Item Group names in get_item_groups ([#57673](https://github.com/frappe/erpnext/issues/57673)) ([a5544d0](a5544d0bfb))
* preserve UOM conversion factor precision in transactions ([e5999b2](e5999b22c7))
* prevent duplicate shipping charges without cost center ([666b616](666b6167a1))
* **projects:** add project filter ([3ba8313](3ba83134af))
* **projects:** include on hold status in project filters and reports ([d104d8e](d104d8e723))
* **purchase:** reject purchase returns where every item has zero quantity ([032b922](032b922f0c))
* **quotation:** carry forward communications from opportunity at after_insert (backport [#57639](https://github.com/frappe/erpnext/issues/57639)) ([#57643](https://github.com/frappe/erpnext/issues/57643)) ([624a236](624a236f88))
* reject the same Material Request item twice in one supplier selection ([ea770f6](ea770f6a8e))
* resolve version 16 backport conflicts ([e98471d](e98471d9c9))
* resolve version-16 backport conflicts ([697f68d](697f68d1d2))
* respect child warehouse account override in Stock and Account Value Comparison ([#57552](https://github.com/frappe/erpnext/issues/57552)) ([ca657d2](ca657d2629))
* respect quantity precision in material transfer validation ([eb969a5](eb969a5866))
* **sales:** reject sales returns where every item has zero quantity ([aa71cd6](aa71cd695b))
* seed standard Item Groups under the existing tree root ([4ffa950](4ffa950aa3)), closes [#57581](https://github.com/frappe/erpnext/issues/57581)
* **selling:** don't require cancel and delete perms to remove items via Update Items (backport [#57419](https://github.com/frappe/erpnext/issues/57419)) ([#57601](https://github.com/frappe/erpnext/issues/57601)) ([04e1ca8](04e1ca8226))
* set Required By on Purchase Orders created per supplier ([f99d66d](f99d66d578))
* set reservation voucher_qty to voucher demand not reserved qty ([ecccedf](ecccedf0ed))
* source manually created asset value from valuation rate ([b556b01](b556b012f0))
* **stock:** accept every number a reading can be written as ([fccf122](fccf1220f6))
* **stock:** correct secondary item valuation across stock entry purposes ([4ed0374](4ed03748fe)), closes [#57732](https://github.com/frappe/erpnext/issues/57732) [#57735](https://github.com/frappe/erpnext/issues/57735) [#57736](https://github.com/frappe/erpnext/issues/57736) [#57737](https://github.com/frappe/erpnext/issues/57737) [#57738](https://github.com/frappe/erpnext/issues/57738)
* **stock:** keep manufactured item rate at zero when inputs are free  (backport [#57334](https://github.com/frappe/erpnext/issues/57334)) ([#57513](https://github.com/frappe/erpnext/issues/57513)) ([49924dd](49924ddd1d))
* **stock:** read quality inspection readings in the user's number format ([3b7fb68](3b7fb6851a))
* **stock:** resolve backport conflict in quality inspection imports ([656db1c](656db1c2fe))
* **stock:** scope over deliver/receive role check to delivery and receipt overflow ([4713ddd](4713ddd55b))
* **stock:** validate only the variant attributes that changed ([0013908](00139081f6))
* **stock:** validate over delivery/receipt allowance in stock settings ([3f3292c](3f3292ca4a))
* **stock:** value batched packed-item returns from the original bundle  (backport [#57327](https://github.com/frappe/erpnext/issues/57327)) ([#57511](https://github.com/frappe/erpnext/issues/57511)) ([c0ac8aa](c0ac8aaf86))
* sync translations from crowdin ([#57741](https://github.com/frappe/erpnext/issues/57741)) ([285aec3](285aec3164))
* update stock variance account logic which defaults to default expense ([#57656](https://github.com/frappe/erpnext/issues/57656)) ([d09c04a](d09c04a623))
* use payment entry posting date for received amount exchange rate (backport [#57660](https://github.com/frappe/erpnext/issues/57660)) ([#57663](https://github.com/frappe/erpnext/issues/57663)) ([5595d1e](5595d1ed2f))
* validate account frozen date ([17aeb0b](17aeb0b55b))
* warn about existing draft orders before the supplier selection creates more ([93331a1](93331a1cf0))

### Features

* adjust the ordered quantity in the supplier selection dialog ([2c8c375](2c8c375ca6))
* alert when Required By falls back to today ([1adeb66](1adeb66bdc))
* auto-fill subscription accounting dimensions from plan with item fallback (backport [#57615](https://github.com/frappe/erpnext/issues/57615)) ([#57622](https://github.com/frappe/erpnext/issues/57622)) ([ebba4e9](ebba4e9958))
* order only the items ticked in the supplier selection dialog ([d6ee543](d6ee5436b8))
* select a supplier per item when creating Purchase Orders from Material Request ([95f7810](95f7810948))
* set one supplier across every item in the supplier selection dialog ([9b647be](9b647bed5c))
* show the UOM alongside the quantity in the supplier selection dialog ([5aaefec](5aaefec747))
* status based bar colors in Work Order gantt view (backport [#57634](https://github.com/frappe/erpnext/issues/57634)) ([#57636](https://github.com/frappe/erpnext/issues/57636)) ([2d03d80](2d03d80269))
2026-08-04 20:30:34 +00:00
Diptanil Saha
2769a8c69e Merge pull request #57781 from frappe/version-16-hotfix
chore: release v16
2026-08-05 01:58:06 +05:30
Diptanil Saha
970a3f403d Merge pull request #57798 from diptanilsaha/backport/16/57734
fix(payment reconciliation): correct supplier gain/loss posting (backport #57734)
2026-08-05 00:13:14 +05:30
Sudharsanan11
61154e22ed test(payment reconciliation): cover supplier exchange gain posting 2026-08-04 23:58:33 +05:30
diptanilsaha
dc907add40 fix(payment reconciliation): correct supplier gain/loss posting 2026-08-04 23:58:19 +05:30
Shllokkk
b5700831d8 Merge pull request #57791 from frappe/mergify/bp/version-16-hotfix/pr-57790
test: child warehouse account override in stock vs account value comparison (backport #57790)
2026-08-04 20:15:59 +05:30
Shllokkk
a703e7a462 test: child warehouse account override excluded in stock vs account value comparison
(cherry picked from commit ef7a3cb4c8)

# Conflicts:
#	erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
2026-08-04 18:54:44 +05:30
Mihir Kandoi
abc76eb49d Merge pull request #57789 from frappe/mergify/bp/version-16-hotfix/pr-57757
fix(opportunity): add validation for positive item quantities (backport #57757)
2026-08-04 17:13:29 +05:30
R-Jayaraman
37e96f931d chore: use flt() in qty check
(cherry picked from commit 69de8f2d62)
2026-08-04 11:24:02 +00:00
R-Jayaraman
a9f969e942 fix(opportunity): add validation for positive item quantities
(cherry picked from commit c47cc37441)
2026-08-04 11:24:01 +00:00
Mihir Kandoi
cd65a6d9ff Merge pull request #57785 from frappe/mergify/bp/version-16-hotfix/pr-57772
fix(accounts): skip party dashboard without invoice permission (backport #57772)
2026-08-04 16:48:17 +05:30
Sudharsanan11
ee6955d56c fix(accounts): skip party dashboard without invoice permission
(cherry picked from commit ed78dd37be)
2026-08-04 11:04:28 +00:00
Mihir Kandoi
02f407b82a Merge pull request #57779 from frappe/mergify/bp/version-16-hotfix/pr-57777
fix(manufacturing): reach the whole configurator from tree toolbar actions (backport #57777)
2026-08-04 16:11:23 +05:30
Mihir Kandoi
281e92fb6e fix(manufacturing): reach the whole configurator from tree toolbar actions
The toolbar handlers were copied onto view.events as unbound functions, so
`this` inside them was that object literal rather than the BOMConfigurator.
They worked only because the literal also carried `frm`, and broke as soon as
a handler called a method the literal did not list: get_item_code, added when
the tree started keying nodes on the row name, threw
"this.get_item_code is not a function" and killed Add Raw Material, Add Sub
Assembly and Convert to Sub Assembly.

Assign the instance instead of a hand-maintained whitelist. Every method is
reachable, `this.frm` keeps working, and no future method can be forgotten.

Fixes #57773

(cherry picked from commit 097ce0f348)
2026-08-04 10:09:12 +00:00
MochaMind
285aec3164 fix: sync translations from crowdin (#57741) 2026-08-04 10:00:24 +00:00
mergify[bot]
824ae57e44 fix: escape data in multiple templates (backport #57742) (#57770)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-04 06:52:25 +00:00
ruthra kumar
4193a441e6 Merge pull request #57767 from frappe/mergify/bp/version-16-hotfix/pr-57719
Fix/reversal journal entry custom remark (backport #57719)
2026-08-04 11:02:08 +05:30
Krishna Shirsath
b4dfca9ef1 fix: allow custom remark on reversal journal entry
(cherry picked from commit 5e0e9ba668)
2026-08-04 05:30:03 +00:00
Diptanil Saha
6153202231 fix(accounts): fetch deferred invoice docs on non-empty sales_docs or purchase_docs in repost accounting ledger (#57753) 2026-08-03 17:25:50 +05:30
Mihir Kandoi
4babce436f Merge pull request #57755 from mihir-kandoi/backport/secondary-item-valuation-fixes
fix(stock): correct secondary item valuation across stock entry purposes
2026-08-03 17:05:54 +05:30
Mihir Kandoi
aaa99f775d test(stock): cover secondary item valuation across stock entry purposes
Ports the five regression tests to this branch's `type` field name.
2026-08-03 16:53:20 +05:30
Mihir Kandoi
4ed03748fe fix(stock): correct secondary item valuation across stock entry purposes
Backport of five fixes merged to develop, adapted to this branch, where
the field is still named `type` and the stock entry rate logic has not
been split out of set_basic_rate.

- A secondary row with no BOM link is costed out of the finished good,
  as legacy scrap was. Finished goods are rated last so a single
  validate pass sees the secondary rows' amounts. (#57732)
- Repack no longer flags secondary rows as finished goods, so each side
  takes the share the BOM declares instead of the scrap absorbing the
  finished good's percentage. (#57735)
- A BOM allocation of 0% means the row carries no cost, rather than
  falling through to the item's own valuation rate. (#57736)
- Secondary Item Type no longer waives a quality inspection on purposes
  that do not produce secondary items. (#57737)
- The BOM allocation applies to the consumption entry's cost when the
  raw material cost comes from one. (#57738)

Replaces the individual backports, which could not be cherry-picked
cleanly: every hunk needed rewriting against the pre-rename field and
the un-refactored rate logic.
2026-08-03 16:53:20 +05:30
Mihir Kandoi
667b012065 Merge pull request #57750 from frappe/mergify/bp/version-16-hotfix/pr-57747
fix: disabled item attribute blocks unrelated edits to existing variants (backport #57747)
2026-08-03 16:33:22 +05:30
Mihir Kandoi
81e24442e3 test(stock): cover editing a variant whose attribute is disabled
Assert that a variant saves after its attribute is disabled when the edit
leaves the attribute rows alone, and that changing an attribute value still
throws.

(cherry picked from commit 8d5326196e)
2026-08-03 10:47:42 +00:00
Mihir Kandoi
00139081f6 fix(stock): validate only the variant attributes that changed
Disabling an Item Attribute writes `disabled = 1` into every Item Variant
Attribute row, including the rows on the template. `validate_variant` runs
on every save and walks the whole attribute table, so any later save of an
existing variant re-checked its untouched rows against the now-disabled
template row and threw. `update_variants` hit the same wall, which made a
single template save fail once an attribute was disabled.

The flag exists to keep an attribute out of new variants, not to freeze the
variants that already use it. item.js only reads it to drop the attribute
from the variant creation dialog.

Skip rows that are unchanged since the last save. New and edited rows are
still checked, so a disabled attribute cannot be added to an existing
variant, and the same guard covers the sibling checks for attributes and
values that the template no longer offers.

(cherry picked from commit 25cd793617)
2026-08-03 10:47:42 +00:00
Henil Maru
a5544d0bfb fix(pos): don't double-escape Item Group names in get_item_groups (#57673)
frappe.db.escape() wraps the value in quotes (e.g. "'Products'").
Callers pass the result into query-builder isin()/frappe.get_all
filters, which parameterize values themselves — so the pre-quoted
string never matches a real Item Group name, and POS shows no items
whenever a POS Profile restricts Item Groups.

Return raw names instead, matching develop.
2026-08-03 16:03:38 +05:30
mergify[bot]
ca6065398c fix(banking): fetch company list from DB instead of boot (backport #57731) (#57739)
fix(banking): fetch company list from DB instead of boot (#57731)

* fix(banking): fetch company list from DB instead of boot

* fix: show error banner for company list fail fetch

(cherry picked from commit abc3da6b97)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-08-03 14:33:46 +05:30
Mihir Kandoi
eeab2a833f Merge pull request #57730 from frappe/mergify/bp/version-16-hotfix/pr-57647
fix(sales): reject sales returns where every item has zero quantity (backport #57647)
2026-08-03 13:44:09 +05:30
Mihir Kandoi
af4aea171b test(sales): import make_sales_return from delivery_note on version-16-hotfix 2026-08-03 13:29:40 +05:30
Mihir Kandoi
a2dfc9e50a Merge pull request #57728 from frappe/mergify/bp/version-16-hotfix/pr-57725
fix(stock): scope over deliver/receive role check to delivery and receipt overflow (backport #57725)
2026-08-03 13:13:22 +05:30
R-Jayaraman
f2a53247c5 test(sales): add coverage for zero-qty return rejection
Greptile flagged that the sales-side zero-qty-return fix had no dedicated
test proving the behavior - the existing suite happened to pass, but
nothing specifically asserted that an all-zero return is rejected while
a normal negative-qty return still succeeds.

Adds two tests covering the doctypes that rely entirely on this check
(no other guard covers them for a non-stock-effect return):
- Delivery Note return with qty 0 -> rejected
- Sales Invoice return with qty 0 (no update_stock) -> rejected

POS Invoice is not covered separately here since it always runs with
update_stock=1, which is already guarded by the pre-existing
validate_zero_qty_for_return_invoices_with_stock check regardless of
this fix.

(cherry picked from commit 732c884633)
2026-08-03 07:35:29 +00:00
R-Jayaraman
aa71cd695b fix(sales): reject sales returns where every item has zero quantity
validate_returned_items() set items_returned=True whenever a row matched
a valid item from the original document, even if its qty was 0. This let
a Sales Invoice, Delivery Note, or POS Invoice return be submitted with
every line at qty=0 - a no-op document with no stock or financial effect
that still consumed a document number and linked back to the original
transaction.

Scoped to the Sales side only: items_returned now flips to True for
Sales Invoice/Delivery Note/POS Invoice only when qty (or received_qty)
is actually negative, so an all-zero sales return correctly hits the
existing "At least one item should be entered with negative quantity"
check. Purchase Invoice, Purchase Receipt, and Subcontracting Receipt
are unchanged.

(cherry picked from commit a3e9d13da3)
2026-08-03 07:35:29 +00:00
Mihir Kandoi
697f68d1d2 fix: resolve version-16 backport conflicts
Keep validate_warehouses() alongside the new
validate_over_delivery_receipt_allowance() call.

Drop test_blanket_order_over_order_aggregated_across_rows: it is develop-only
context the cherry-pick swallowed into the conflict, not part of #57725.

Revert the valuation_method literal to the three options this branch offers -
Standard Cost rode along from a regenerated develop type block.
2026-08-03 12:55:08 +05:30
Mihir Kandoi
246ffee17c Merge pull request #57722 from frappe/mergify/bp/version-16-hotfix/pr-57645
fix(purchase): reject purchase returns where every item has zero quan… (backport #57645)
2026-08-03 12:54:59 +05:30
Mihir Kandoi
e7757f6d0b Merge pull request #57726 from frappe/mergify/bp/version-16-hotfix/pr-57097
fix(stock): read quality inspection readings in the user's number format  (backport #57097)
2026-08-03 12:42:52 +05:30
Mihir Kandoi
10229700c0 test(purchase): drop unrelated sales-return test from the backport
test_sales_return_validates_against_original came in with the new file,
not with the change being backported. It covers a raw-SQL to query-builder
conversion that only exists on develop, and it imports
erpnext.stock.doctype.delivery_note.mapper, a module version-16-hotfix
does not have.
2026-08-03 12:42:10 +05:30
Afsal Syed
6cbf73a326 test(stock): prevent settings leakage in purchase order tests
(cherry picked from commit 99630f40eb)
2026-08-03 07:10:14 +00:00
Afsal Syed
3a0f988a9e test(stock): add test cases verifying stock over delivery role does not bypass order allowance
(cherry picked from commit 0b271e24b6)

# Conflicts:
#	erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py
2026-08-03 07:10:14 +00:00
Afsal Syed
4713ddd55b fix(stock): scope over deliver/receive role check to delivery and receipt overflow
(cherry picked from commit 248873034d)
2026-08-03 07:10:14 +00:00
Afsal Syed
3f3292ca4a fix(stock): validate over delivery/receipt allowance in stock settings
(cherry picked from commit 446ec6030a)

# Conflicts:
#	erpnext/stock/doctype/stock_settings/stock_settings.json
#	erpnext/stock/doctype/stock_settings/stock_settings.py
2026-08-03 07:10:14 +00:00
Mihir Kandoi
656db1c2fe fix(stock): resolve backport conflict in quality inspection imports
The backport left both import hunks unresolved, so the file did not compile.
version-16-hotfix keeps item_query unannotated and still imports cstr, so only
get_number_format_info goes, replaced by NumberFormat; typing.Any is not
carried over because nothing on this branch uses it.
2026-08-03 12:31:55 +05:30
Mihir Kandoi
444dd9e817 test(stock): cover reading number formats end to end
Set the number format on the session user rather than on System Settings: the
code reads the user default, which shadows the global one, so these tests never
exercised the path they were written for. Restoring it in a finally also keeps
a failed assertion from leaving the whole suite in another locale.

Add a table test over every format in NUMBER_FORMAT_MAP, covering the grouped
values and the three formats parse_float used to read as 0, and restore the
formula-based coverage for non-numeric readings.

(cherry picked from commit 00d17ca5db)
2026-08-03 06:56:49 +00:00
Mihir Kandoi
fccf1220f6 fix(stock): accept every number a reading can be written as
parse_float and is_valid_number each re-derived the number grammar, so the
validator accepted strings flt() cannot parse: str.isdigit() lets superscripts
through and lstrip("+-") lets repeated signs through, both then silently scored
as 0. One parse_reading() returning None when float() refuses the value makes
acceptance and conversion true by construction.

The grammar was also wrong for several formats. Where the group separator is
not a dot, a dot-decimal reading such as 1.15 parsed correctly before and is
accepted again. #,### and #.### report no decimal separator at all, which
rejected every fractional reading outright and, for #.###, reread a stored
1.500 as 1500.0; they now fall back to a dot and give up the grouping that
would collide with it.

Only readings that change are checked, so an inspection entered by a user in
one locale stays saveable and submittable by a user in another, and manual
inspection rows keep the free text they were never parsed for.

NumberFormat replaces get_number_format_info, which frappe drops in v16.

(cherry picked from commit 5b5f354090)

# Conflicts:
#	erpnext/stock/doctype/quality_inspection/quality_inspection.py
2026-08-03 06:56:48 +00:00
Sudharsanan11
113b5ecaec test(stock): cover quality inspection readings in every number format
covers the reported case, a 1,15 reading in the space grouped "# ###,##"
format, which was read as 115 and rejected. also covers the dot grouped
comma format, and asserts that a reading written with the wrong separator,
or one that is not a number at all, is now rejected with an error rather
than read as a different value.

(cherry picked from commit b1f188146e)
2026-08-03 06:56:48 +00:00
Sudharsanan11
e2466780b9 test(stock): drop non numeric reading from formula based quality inspection
a numeric reading of "random text" was read as 0 and pulled the mean from
0.6 down to 0.4, which the test then asserted as accepted. such a reading
is now rejected outright, and the test is about formula evaluation, so drop
the row. its assertions are unchanged.

(cherry picked from commit 3752be809f)
2026-08-03 06:56:47 +00:00
Sudharsanan11
3b7fb6851a fix(stock): read quality inspection readings in the user's number format
readings are Data fields, so they are parsed server side. parse_float only
swapped the separators for "#.###,##", so in the space grouped "# ###,##"
(polish) a reading of 1,15 was read as 115, fell outside the acceptance
range and silently rejected the inspection. strip whatever the group
separator is and normalise whatever the decimal separator is instead.

it also read the global number format, while the desk formats numbers with
the user's own. a user whose locale differs from the site therefore typed
readings in a format the server did not parse them with. read the user
default, which falls back to the global one.

a reading that is not a valid number in that format is now rejected with an
error instead of being read as a different number.

(cherry picked from commit e74c0a3cdb)
2026-08-03 06:56:46 +00:00
R-Jayaraman
b0f2704bde test(purchase): add coverage for zero-qty return rejection
(cherry picked from commit cde2963da1)

# Conflicts:
#	erpnext/controllers/tests/test_sales_and_purchase_return.py
2026-08-03 06:17:09 +00:00
R-Jayaraman
032b922f0c fix(purchase): reject purchase returns where every item has zero quantity
validate_returned_items() set items_returned=True whenever a row matched
a valid item from the original document, even if its qty was 0. This let
a Purchase Invoice, Purchase Receipt, or Subcontracting Receipt return be
submitted with every line at qty=0 - a no-op document with no stock or
financial effect that still consumed a document number and linked back
to the original transaction.

Scoped to the Purchase side only: items_returned now flips to True for
Purchase Invoice/Purchase Receipt/Subcontracting Receipt only when qty
(or received_qty) is actually negative, so an all-zero purchase return
correctly hits the existing "At least one item should be entered with
negative quantity" check. Sales Invoice, Delivery Note, and POS Invoice
are unchanged.

Also applies a corresponding check to the item_name-only fallback branch
(for rows without an item_code - Item Code is not mandatory on Purchase
Invoice Item), which previously bypassed this fix entirely and still set
items_returned=True unconditionally regardless of quantity. For that
branch specifically, only qty is checked (not received_qty): with no
linked Item there's no accepted/rejected split, so received_qty carries
no independent meaning and a qty=0 row must be rejected regardless of
its value.

(cherry picked from commit b63066ed44)
2026-08-03 06:17:08 +00:00
MochaMind
833ccd3358 chore: update POT file (#57706) 2026-08-02 14:31:18 +02:00
Mihir Kandoi
4a5c416ee0 Merge pull request #57698 from frappe/mergify/bp/version-16-hotfix/pr-57676
feat: select a supplier per item when creating Purchase Orders from a Material Request (backport #57676)
2026-08-02 12:35:56 +05:30
Mihir Kandoi
4b6a4cc9c5 Merge pull request #57701 from frappe/mergify/bp/version-16-hotfix/pr-57699
fix: prevent duplicate shipping charges without cost center (backport #57699)
2026-08-02 12:29:29 +05:30
Mihir Kandoi
e5f8d0c84b fix: apply the supplier selection to this branch's own mapper module
The backport carried develop's mapper module across whole, while version 16
keeps its mappers in material_request.py. That left two copies of the mapping
layer: the dialog and the new tests reached for the imported module, and
make_purchase_order, which the rest of the branch and the older tests use, never
learned to set the supplier - so test_make_purchase_order_sets_supplier failed.

The feature now sits in material_request.py alongside the mappers it extends,
and the imported module is dropped.
2026-08-02 12:25:22 +05:30
Mihir Kandoi
7f81502cde chore: remove shipping rule comments
(cherry picked from commit 106ecd7120)
2026-08-02 06:49:06 +00:00
Mihir Kandoi
666b6167a1 fix: prevent duplicate shipping charges without cost center
(cherry picked from commit a4134af30b)
2026-08-02 06:49:06 +00:00
Mihir Kandoi
65a53a7012 Merge pull request #57697 from frappe/mergify/bp/version-16-hotfix/pr-57674
fix: preserve UOM conversion factor precision in transactions (backport #57674)
2026-08-02 12:06:19 +05:30
Mihir Kandoi
e98471d9c9 fix: resolve version 16 backport conflicts 2026-08-02 12:03:47 +05:30
Mihir Kandoi
187840b559 fix: label the items table in the supplier selection dialog
The grid template always renders its label line, so leaving the table unlabelled
left an empty line hanging above the description.

(cherry picked from commit 2e72846670)
2026-08-02 06:26:26 +00:00
Mihir Kandoi
2c9db13041 fix: keep the bulk supplier field to half the supplier selection dialog
A lone Link field stretched the full width of the dialog, which reads as a
search bar rather than a field. A column break holds it to half.

(cherry picked from commit 44fdf7bea9)
2026-08-02 06:26:26 +00:00
Mihir Kandoi
9b647bed5c feat: set one supplier across every item in the supplier selection dialog
A Material Request where few items carry a default supplier meant picking the
same supplier row by row. A Supplier field above the table copies its value
into every row, leaving the exceptions to be corrected by hand.

Both pickers skip suppliers that are disabled or barred from Purchase Orders by
their scorecard standing.

(cherry picked from commit e84bf44e51)
2026-08-02 06:26:25 +00:00
Mihir Kandoi
93331a1cf0 fix: warn about existing draft orders before the supplier selection creates more
Creating through the dialog calls the endpoint directly instead of going
through open_mapped_doc, so the draft link guard that every other Create action
runs never fired, and a repeated dialog quietly produced a second set of draft
orders for the same quantity.

(cherry picked from commit f0bb70539d)
2026-08-02 06:26:25 +00:00
Mihir Kandoi
380ee3b013 test: reject the same Material Request item twice in one supplier selection
(cherry picked from commit 8ffe5ba420)
2026-08-02 06:26:25 +00:00
Mihir Kandoi
ea770f6a8e fix: reject the same Material Request item twice in one supplier selection
Each row was checked against the pending quantity on its own, so a payload that
listed one item under two suppliers passed both checks and ordered the pending
quantity twice. The dialog cannot produce that, a direct call to the endpoint
can.

(cherry picked from commit 99d56cc850)
2026-08-02 06:26:25 +00:00
Mihir Kandoi
06a753faf3 fix: escape item code and UOM in the supplier dialog errors
Desk renders a client side message as HTML, so an Item or UOM whose name holds
markup ran as markup in the buyer's session.

(cherry picked from commit 21c6d10ad3)
2026-08-02 06:26:24 +00:00
Mihir Kandoi
e71cef02b3 fix: open the Purchase Order when the supplier selection creates only one
Naming a single order in a message and leaving the buyer to click it is a step
for nothing. The form opens directly when there is one order; the message stays
for the case it was meant for, several orders at once.

(cherry picked from commit 3856eaa35e)
2026-08-02 06:26:24 +00:00
Mihir Kandoi
6096e761b0 test: reject a supplier selection without items
(cherry picked from commit d233fdf198)
2026-08-02 06:26:24 +00:00
Mihir Kandoi
d6ee5436b8 feat: order only the items ticked in the supplier selection dialog
Every row is ticked when the dialog opens, so the common case of ordering
everything is unchanged, and a buyer who wants a partial order unticks what
should wait. Creating with nothing ticked is rejected.

(cherry picked from commit 07445b3675)
2026-08-02 06:26:23 +00:00
Mihir Kandoi
810b9ae28f fix: link the item and spell out the unit in the supplier dialog errors
A bare item code left the buyer to find the item themselves, and a bare number
gave no clue what the limit was counted in. Both messages now link the item and
state the pending quantity in bold with its UOM.

(cherry picked from commit 5a78e2290a)
2026-08-02 06:26:23 +00:00
Mihir Kandoi
6b056ebb36 test: alert when Required By falls back to today
(cherry picked from commit 671c289303)
2026-08-02 06:26:22 +00:00
Mihir Kandoi
1adeb66bdc feat: alert when Required By falls back to today
Items whose requested date has passed silently got today as Required By, which
is a date the buyer never asked for. A toast now says so.

(cherry picked from commit 53e09dfdd6)
2026-08-02 06:26:22 +00:00
Mihir Kandoi
5aaefec747 feat: show the UOM alongside the quantity in the supplier selection dialog
The quantity is meaningless without the unit it is counted in, which the buyer
had to look up on the Material Request itself.

(cherry picked from commit d0cae2eb9c)
2026-08-02 06:26:22 +00:00
Mihir Kandoi
d2fe4b623c fix: list the Purchase Orders created per supplier instead of opening one
Opening one of several created orders hid the rest and moved the buyer off the
Material Request. The created orders are now reported the way Production Plan
reports its documents, as links in a message, and the form stays put.

(cherry picked from commit 6f22551aae)
2026-08-02 06:26:22 +00:00
Mihir Kandoi
d718110216 test: Required By on Purchase Orders created per supplier
Backdates the Material Request item so the mapper drops its schedule date, and
asserts the created order still saves with today as Required By.

(cherry picked from commit 15d10bbaf1)
2026-08-02 06:26:21 +00:00
Mihir Kandoi
f99d66d578 fix: set Required By on Purchase Orders created per supplier
Mapping drops a schedule date that already passed, leaving the buyer to pick a
new one on the Purchase Order form. Nothing fills it in when the orders are
created straight from the supplier selection dialog, so a Material Request
whose required date has gone by failed to save with "Please enter the Required
By".

Items that lose their date now fall back to today, which is the earliest date a
Purchase Order raised today accepts.

(cherry picked from commit d05bd80b1e)
2026-08-02 06:26:21 +00:00
Mihir Kandoi
f19aa957cb test: quantity handling in the supplier selection dialog
Asserts the requested quantity reaches the Purchase Order item and that rows
without a supplier, or with a quantity that is zero, negative or beyond the
pending quantity, are rejected.

(cherry picked from commit 09cfd1fe91)
2026-08-02 06:26:21 +00:00
Mihir Kandoi
2c8c375ca6 feat: adjust the ordered quantity in the supplier selection dialog
The dialog prefilled the pending quantity of each Material Request item but
kept it read only, so ordering less than what was requested meant editing the
Purchase Order afterwards.

The quantity is now editable and is validated against the pending quantity of
its Material Request item, both in the dialog and on the server. The requested
quantity is handed to the mapper as the pending quantity of the source row, so
the existing mapping - including the subcontracting conversions - derives the
Purchase Order quantities from it unchanged.

(cherry picked from commit da83370c5c)
2026-08-02 06:26:20 +00:00
Mihir Kandoi
f4d3b2771b test: supplier selection when creating Purchase Orders from Material Request
Covers the default supplier lookup for pending items, the supplier passed
through to a single mapped order, the grouping of items into one order per
supplier, and the failure when an item is sent without a supplier.

(cherry picked from commit 65be201ed6)

# Conflicts:
#	erpnext/stock/doctype/material_request/test_material_request.py
2026-08-02 06:26:20 +00:00
Mihir Kandoi
95f7810948 feat: select a supplier per item when creating Purchase Orders from Material Request
Creating a Purchase Order from a Material Request mapped every pending item
into a single order, leaving the buyer to split it by hand whenever the items
came from different vendors.

The Create action now reads the default supplier of each pending item (item,
item group, then brand defaults). When the items resolve to more than one
distinct supplier - including the case where only some of them have a default -
a dialog lists the items with their default supplier prefilled and editable.
Submitting it groups the items by the chosen supplier and creates one draft
Purchase Order per group.

When every item resolves to the same supplier the order is mapped straight
away with that supplier set, and when none of them has a default supplier the
previous behaviour is unchanged.

(cherry picked from commit e8df7b4a90)

# Conflicts:
#	erpnext/stock/doctype/material_request/mapper.py
#	erpnext/stock/doctype/material_request/material_request.js
2026-08-02 06:26:20 +00:00
Mihir Kandoi
5463bd93aa test: fractional conversion factor survives Material Request to Purchase Order
Fails before the fix with 0.45 != 0.453592292 on a site with Float
Precision 2, and 0.454 on the default of 3.

(cherry picked from commit f4d70c2d60)
2026-08-02 06:25:58 +00:00
Mihir Kandoi
e5999b22c7 fix: preserve UOM conversion factor precision in transactions
calculate_item_values rounds every Float field on an item row to the
site's Float Precision (3 by default), and conversion_factor was one of
them. The factor is a ratio, not a rate: UOM Conversion Factor.value is
stored at precision 9, and Material Request keeps the full value because
it has no currency field and so never runs the calculation.

Mapping a Material Request to a Purchase Order therefore truncated the
factor - 0.453592292 for Pound -> Kg became 0.454 - and stock_qty, which
is recomputed as qty * conversion_factor, drifted from the quantity that
was requested, leaving the Material Request unable to close.

Exclude conversion_factor from the rounded fields on the server and on
the client. Factors below the site precision would otherwise round to
zero outright.

(cherry picked from commit 269cc6ee3b)
2026-08-02 06:25:58 +00:00
Shllokkk
1fbccd9823 Merge pull request #57694 from frappe/mergify/bp/version-16-hotfix/pr-57681
fix: set reservation voucher_qty to voucher demand not reserved qty (backport #57681)
2026-08-01 16:09:20 +05:30
Shllokkk
9457cae327 test: partial work order reservation records full voucher_qty
(cherry picked from commit 7a97dc3361)
2026-08-01 10:12:50 +00:00
Shllokkk
ecccedf0ed fix: set reservation voucher_qty to voucher demand not reserved qty
(cherry picked from commit 7995bb9960)
2026-08-01 10:12:50 +00:00
Diptanil Saha
5633c29223 Merge pull request #57692 from frappe/mergify/bp/version-16-hotfix/pr-57201
fix: permission checks on various whitelisted methods (backport #57201)
2026-08-01 15:12:53 +05:30
diptanilsaha
fcbbb251cf fix(payment_request): added permission checks on resend_payment_email
(cherry picked from commit 0659bd7049)
2026-08-01 15:01:34 +05:30
diptanilsaha
c7cf9d868b fix(item_variant): added permission checks on enqueue_multiple_variant_creation
(cherry picked from commit 3b0cbc972e)
2026-08-01 15:01:27 +05:30
diptanilsaha
99f249b1a0 fix(assets): add permission checks on whitelisted methods on asset_capitalization
(cherry picked from commit 09d721d1be)
2026-08-01 14:55:42 +05:30
Mihir Kandoi
38f7c824f1 Merge pull request #57683 from frappe/mergify/bp/version-16-hotfix/pr-57679
fix: exclude transferred and consumed qty from dashboard reserved stock (backport #57679)
2026-08-01 14:50:56 +05:30
Shllokkk
46317b063a fix: exclude transferred and consumed qty from dashboard reserved stock
(cherry picked from commit 6c36624d91)
2026-08-01 09:03:51 +00:00
Shllokkk
2ce88af3c9 Merge pull request #57672 from frappe/mergify/bp/version-16-hotfix/pr-57668
fix: drop row prefix in reserve stock message when row is unknown (backport #57668)
2026-07-31 23:31:20 +05:30
Shllokkk
684ae4d762 fix: drop row prefix in reserve stock message when row is unknown
(cherry picked from commit 517053bc25)
2026-07-31 22:51:30 +05:30
Sudharsanan Ashok
d09c04a623 fix: update stock variance account logic which defaults to default expense (#57656)
* fix(stock): update stock variance account logic which defaults to default expense account set in company

* test: add regression test for purchase invoice stock adjustment account fallback

---------

Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
2026-07-31 22:10:15 +05:30
mergify[bot]
624a236f88 fix(quotation): carry forward communications from opportunity at after_insert (backport #57639) (#57643)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 20:56:21 +05:30
mergify[bot]
7bc04752fc fix(plant_floor): add missing perm check on get_stock_summary (backport #57667) (#57670)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 15:19:50 +00:00
Mihir Kandoi
810da8f542 Merge pull request #57655 from aerele/backport-57567-version-16-hotfix
fix: guard against None row in get_stock_balance_for (backport #57567)
2026-07-31 18:56:42 +05:30
pandiyan
283ee6e07b fix: guard against None row in get_stock_balance_for (backport #57567)
get_stock_balance_for() takes row=None by default, but the batch-tracked
branch dereferenced it unconditionally while the two neighbouring row
accesses already guard. Calling it with a batch_no and no row raised
AttributeError: 'NoneType' object has no attribute 'use_serial_batch_fields'.

semgrep's missing-argument-type-hint rule matches the whole function body,
so touching any line inside it re-fingerprints the pre-existing untyped
arguments and reports them as introduced by this PR. Silenced with
nosemgrep instead of annotating: on a whitelisted method the hints are
enforced at runtime by pydantic, which is not a risk worth taking on a
hotfix branch.
2026-07-31 18:11:02 +05:30
Mihir Kandoi
e3d8336213 Merge pull request #57658 from aerele/fix/material-transfer-qty-precision-v16
fix: respect quantity precision in material transfer validation
2026-07-31 17:59:46 +05:30
mergify[bot]
5595d1ed2f fix: use payment entry posting date for received amount exchange rate (backport #57660) (#57663)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 12:29:38 +00:00
mergify[bot]
ebba4e9958 feat: auto-fill subscription accounting dimensions from plan with item fallback (backport #57615) (#57622)
* feat: auto-fill subscription accounting dimensions from plan with item fallback (#57615)

When a plan is selected in the Subscription's Plans table, the Subscription's
accounting dimensions (cost center and any custom dimensions) auto-fill from the
plan, falling back to the plan item's company default (selling cost center for a
Customer, buying for a Supplier). Only empty fields are filled. Stale async
responses are ignored so a quick re-pick of the plan can't be overwritten.

(cherry picked from commit 7febc28ed6)

# Conflicts:
#	erpnext/accounts/doctype/subscription/subscription.js
#	erpnext/accounts/doctype/subscription/test_subscription.py

* fix: resolve backport merge conflicts for #57615

---------

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-07-31 16:03:23 +05:30
Sudharsanan11
59bb56aa8d test: cover material transfer quantity precision 2026-07-31 14:41:53 +05:30
Sudharsanan11
eb969a5866 fix: respect quantity precision in material transfer validation 2026-07-31 14:41:53 +05:30
mergify[bot]
9b452f12b7 fix(accounts receivable): made territory field multi select (backport #57322) (#57346)
fix(accounts receivable): made territory field multi select (#57322)

(cherry picked from commit 1029cd988a)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-31 14:41:15 +05:30
mergify[bot]
c0ac8aaf86 fix(stock): value batched packed-item returns from the original bundle (backport #57327) (#57511)
fix(stock): value batched packed-item returns from the original bundle  (#57327)

* fix(stock): value batched packed-item returns from the original bundle

when a return delivery note or sales invoice bundle is built via the
use_serial_batch_fields / sle-driven path, its voucher_detail_no keeps the
packed item instead of being remapped to the parent dn/si item. the return
valuation lookup then misses and the bundle values at zero, so the sle
stock_value_difference stays wrong even after a repost.

resolve the original dn/si item via the packed item's parent_detail_docname
when the direct lookup fails, so the return values from the original outward
bundle on both submit and repost.

* test(stock): cover batched packed-item return valuation on repost

(cherry picked from commit d37e905322)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-31 12:17:54 +05:30
mergify[bot]
0e46937f60 fix: filter Accounts Receivable by invoice sales partner (backport #57628) (#57648)
fix: filter Accounts Receivable by invoice sales partner (#57628)

Filter Accounts Receivable and AR Summary on the Sales Invoice's own
sales_partner instead of the customer's default_sales_partner, and read
the Sales Partner column from the invoice. Returns are attributed to the
invoice they settle, matching how the Sales Person filter works.

(cherry picked from commit fd7765ac02)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-31 12:06:36 +05:30
Shllokkk
0c0350110e Merge pull request #57557 from frappe/mergify/bp/version-16-hotfix/pr-57552
fix: respect child warehouse account override in Stock and Account Value Comparison (backport #57552)
2026-07-31 11:57:21 +05:30
Mihir Kandoi
a620648471 Merge pull request #57243 from frappe/mergify/bp/version-16-hotfix/pr-57223
fix(projects): include on hold status in project filters and reports (backport #57223)
2026-07-31 11:50:06 +05:30
Mihir Kandoi
5a75be871a Merge pull request #57638 from frappe/mergify/bp/version-16-hotfix/pr-57606
fix: guard scio row lookup in stock entry items_add (backport #57606)
2026-07-31 11:48:49 +05:30
Poovetha
3ba83134af fix(projects): add project filter
(cherry picked from commit 7248961568)
2026-07-31 11:07:35 +05:30
Poovetha
76fce556c8 test(projects): add test to ensure on hold project retains status
(cherry picked from commit 79e5ccd370)
2026-07-31 11:07:35 +05:30
Poovetha
d104d8e723 fix(projects): include on hold status in project filters and reports
(cherry picked from commit 51a9fc0316)
2026-07-31 11:07:35 +05:30
ruthra kumar
249acdd7e2 Merge pull request #57641 from frappe/mergify/bp/version-16-hotfix/pr-57434
fix: update doc status in period closing voucher (backport #57434)
2026-07-31 10:48:14 +05:30
nareshkannasln
17aeb0b55b fix: validate account frozen date
(cherry picked from commit b3c2ba5381)
2026-07-31 05:05:52 +00:00
mergify[bot]
2d03d80269 feat: status based bar colors in Work Order gantt view (backport #57634) (#57636)
feat: status based bar colors in Work Order gantt view (#57634)

(cherry picked from commit d59c5e36bc)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-31 09:58:54 +05:30
pandiyan
fb6c87dd0c fix: guard scio row lookup in stock entry items_add
check the result of find() before reading t_warehouse off it. on a
'receive from customer' entry with no row carrying scio_detail, find()
returns undefined and items_add throws a typeerror.

the throw rejects the serially-run handler chain, so the stock entry
controller's own items_add never runs and the new row silently loses
its target warehouse, expense account, cost center and serial/batch
field defaults.

leave t_warehouse unset when no reference row exists, so the rest of
the chain still runs.

(cherry picked from commit 6e444a1832)
2026-07-31 03:26:57 +00:00
mergify[bot]
34cbd3c8d5 fix: do not fetch a random inventory account when multiple inventory accounts exist (backport #57626) (#57632)
* fix: do not fetch a random inventory account when multiple inventory accounts exist (#57626)

(cherry picked from commit 386a4ac1f0)

# Conflicts:
#	erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py

* chore: fix conflicts

Remove redundant inter-company transaction tests and related setup.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-30 23:25:18 +05:30
Mihir Kandoi
9c86f98e5f Merge pull request #57630 from frappe/mergify/bp/version-16-hotfix/pr-57616
fix: seed standard Item Groups under the existing tree root (backport #57616)
2026-07-30 19:39:13 +05:30
Mihir Kandoi
d1d214ddee chore: fix import order in item group tests 2026-07-30 19:18:07 +05:30
Mihir Kandoi
4ffa950aa3 fix: seed standard Item Groups under the existing tree root
install_fixtures always inserted "All Item Groups" as a parentless group.
On a site where another app had already created the root, ItemGroup.validate
re-parented it, leaving a second group-root that held the standard groups
while the real root held everything else.

This is reproducible with the healthcare app on a non-English site: its
after_install seeds the root as _("All Item Groups"), so a pt-BR site gets
"Todos os Grupos de Itens" as the root before the setup wizard runs. The
split predates #57390 -- the old translated-name lookup resolved to the same
root and produced an identical tree.

Resolve the root once with get_root_of (falling back to the canonical English
name on fresh installs) and use it for the root record's exists-guard and the
standard groups' parent, matching Company.create_default_departments.

Patch merges an already-seeded "All Item Groups" into the root it sits under,
lifting its children and repointing every link.

Closes #57581

(cherry picked from commit e7088d8981)
2026-07-30 13:42:04 +00:00
Shllokkk
43fd439866 Merge pull request #57620 from frappe/mergify/bp/version-16-hotfix/pr-57618
fix: source manually created asset value from valuation rate (backport #57618)
2026-07-30 15:21:11 +05:30
Shllokkk
aa60192ab7 refactor: add type-hints for get_values_from_purchase_doc in asset 2026-07-30 15:03:04 +05:30
Shllokkk
b556b012f0 fix: source manually created asset value from valuation rate
(cherry picked from commit 46e01c2d92)
2026-07-30 09:23:30 +00:00
mergify[bot]
49924ddd1d fix(stock): keep manufactured item rate at zero when inputs are free (backport #57334) (#57513)
fix(stock): keep manufactured item rate at zero when inputs are free  (#57334)

* fix(stock): keep manufactured item rate at zero when inputs are free

when a finished item is produced from raw materials consumed at zero
valuation, the incoming rate fell back to the item's own valuation
rate (or BOM cost), valuing free inputs as output and inflating the fg
value on every production run.

add has_consumption_basis() to detect when the consumed cost is known
even if it is zero (consumed rows present, or a consumption entry
exists for the work order). when it is, skip the get_valuation_rate and
BOM-cost fallbacks so a real cost of zero is preserved.

* test(stock): cover manufacture rate for zero-valued raw materials

- manufacture from a free input keeps fg basic_rate and sle
  incoming_rate/stock_value_difference at zero even when the fg already
  carries a valuation in the target warehouse
- material consumption on with no consumption entry does not fall back
  to bom/price-list rate for free inputs
- zero-valued consumption entry keeps the manufacture entry's fg rate
  at zero

(cherry picked from commit 73224d3650)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-30 09:25:35 +05:30
mergify[bot]
1ea1ce15d0 fix(accounts): update AU standard chart of accounts (backport #57145) (#57608)
fix(accounts): update AU standard chart of accounts (#57145)


(cherry picked from commit fee3a6e0fd)

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
Co-authored-by: Jebajebas <jeba.j@arus.co.in>
2026-07-30 02:32:38 +00:00
Mihir Kandoi
04e1ca8226 fix(selling): don't require cancel and delete perms to remove items via Update Items (backport #57419) (#57601)
Row removal called cancel() and delete() on the child row, and both check
permissions against the parent doctype. Dropping a row therefore needed Cancel
and Delete on the order, while the rest of the dialog only needs Write: the
button is gated on has_perm("write"), update_child_qty_rate checks parent
Write, and edits save with ignore_permissions=True.

Set ignore_permissions on the row before cancel/delete so removal sits behind
the same parent Write check as add and edit. validate_child_on_delete is
unchanged, so rows with ordered, received, delivered or billed qty are still
refused.

On version-16-hotfix validate_and_delete_children still lives in
erpnext/controllers/accounts_controller.py, not the extracted
erpnext/accounts/services/child_item_update.py module it was moved to on
develop.

Co-authored-by: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com>
2026-07-29 11:30:36 +00:00
mergify[bot]
16be0f0944 fix: let Purchase Receipt cancel defer to Frappe's linked-document check (backport #57592) (#57597)
fix: let Purchase Receipt cancel defer to Frappe's linked-document check (#57592)

on_cancel pre-blocked cancellation with its own "Purchase Invoice is
already submitted" guard, duplicating the check Frappe already runs for any
submitted linked document. Drop the guard and the unused check_next_docstatus()
method it mirrored so the receipt defers to the framework: the Cancel All
Documents flow cancels the invoice first and then the receipt, and a direct
cancel is still rejected by Frappe's linked-document check.

Add a regression test that a direct cancel of a receipt with a submitted
invoice is rejected and rolls back, leaving no stray stock or GL entries.

(cherry picked from commit cfe18e8427)

# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
#	erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-29 15:24:08 +05:30
Krishna Pramod Shirsath
7daa1dacc3 Merge pull request #57590 from frappe/mergify/bp/version-16-hotfix/pr-57314
fix(italy): skip e-invoicing for opening invoices (backport #57314)
2026-07-29 13:43:52 +05:30
mergify[bot]
87735b1f68 refactor(accounts): repost accounting ledger (backport #56442) (#57585)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-29 13:06:18 +05:30
Krishna Shirsath
5e584d1cfb fix(italy): skip e-invoicing for opening invoices
(cherry picked from commit f328018bfb)
2026-07-29 05:39:39 +00:00
Mihir Kandoi
123e205bbd Merge pull request #57559 from aerele/backport-57335-version-16-hotfix
refactor: reuse shared date range validation across reports
2026-07-29 07:54:46 +05:30
Frappe PR Bot
8378b6e203 chore(release): Bumped to Version 16.30.0
# [16.30.0](https://github.com/frappe/erpnext/compare/v16.29.0...v16.30.0) (2026-07-28)

### Bug Fixes

* add permission check for `get_item_details` (backport [#57515](https://github.com/frappe/erpnext/issues/57515)) ([#57551](https://github.com/frappe/erpnext/issues/57551)) ([7a606ab](7a606ab91c))
* bump item doctype modified timestamp so description change syncs on migrate ([2471c5c](2471c5ccbb))
* **crm:** align Opportunity status checks with Quotation statuses (backport [#57489](https://github.com/frappe/erpnext/issues/57489)) ([#57491](https://github.com/frappe/erpnext/issues/57491)) ([ad9870a](ad9870acc1))
* **crm:** clarify the reason why an opportunity cannot be declared as lost (backport [#57495](https://github.com/frappe/erpnext/issues/57495)) ([#57498](https://github.com/frappe/erpnext/issues/57498)) ([3ea19d8](3ea19d8eb1))
* detect the currency column by fieldtype in trends total row ([a30aac8](a30aac87bf))
* enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (backport [#57458](https://github.com/frappe/erpnext/issues/57458)) ([#57460](https://github.com/frappe/erpnext/issues/57460)) ([a0af717](a0af717234))
* enhance growth view filtering by validating period keys ([3f87836](3f87836536))
* force-delete repost data file during cleanup (backport [#57245](https://github.com/frappe/erpnext/issues/57245)) ([32baf6a](32baf6a47c))
* get reserved batch qty precision from settings ([95de237](95de2374ed))
* guard against missing is_your_company_address custom field on address ([2e6b4b5](2e6b4b5838))
* Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime  (backport [#57380](https://github.com/frappe/erpnext/issues/57380)) ([#57397](https://github.com/frappe/erpnext/issues/57397)) ([84813d7](84813d7f46))
* **item:** correct description on deferred revenue/expense ([ee12f8d](ee12f8d2d4))
* **manufacturing:** exclude corrective job cards from semi-FG aggregate ([ebc8482](ebc8482310))
* **manufacturing:** fall back to UOM Conversion Factor in Production Plan (backport [#57553](https://github.com/frappe/erpnext/issues/57553)) ([#57554](https://github.com/frappe/erpnext/issues/57554)) ([8f36753](8f36753705))
* **manufacturing:** scope BOM Creator tree children to the parent row ([339bb0b](339bb0b4ea)), closes [#57311](https://github.com/frappe/erpnext/issues/57311)
* **manufacturing:** sum semi-FG qty across split job cards ([c411b8e](c411b8e471))
* **manufacturing:** update cost of BOMs created via BOM Creator ([f1a0a5e](f1a0a5e1bf))
* map MT940 per-transaction reference from :61: customer_reference ([b9c4f79](b9c4f790bd))
* map pick list customer to delivery note when no sales order ([3f6501b](3f6501b4ff)), closes [#57412](https://github.com/frappe/erpnext/issues/57412)
* migrate stored AR/AP ageing filter to renamed field ([2e5fc38](2e5fc38179))
* pool batch slot values on every run, not only when negative ([5763378](5763378ee1))
* **quotation:** carry forward communications from opportunity (backport [#57507](https://github.com/frappe/erpnext/issues/57507)) ([#57509](https://github.com/frappe/erpnext/issues/57509)) ([f9b3e42](f9b3e42dcd))
* rebalance batch slot values at the pooled rate when driven negative ([8a50572](8a50572786))
* recalculate operating cost on hour rate change in routing ([c182b40](c182b4085b))
* recover failed POS closings (backport [#57203](https://github.com/frappe/erpnext/issues/57203)) ([#57572](https://github.com/frappe/erpnext/issues/57572)) ([9c946eb](9c946eb168))
* rename misleading filter labels in AR/AP reports ([6d856fa](6d856fa632))
* resolve backport conflicts in item doctype ([0ce5040](0ce50407f9))
* respect selected BOM when creating work order for variant item ([#57359](https://github.com/frappe/erpnext/issues/57359)) ([1132eb1](1132eb1a0f))
* respect user permissions in party dashboard company list ([c79f2e4](c79f2e45a8)), closes [frappe/erpnext#57428](https://github.com/frappe/erpnext/issues/57428)
* **setup:** scope manufacturing warehouse filters to company ([f05e8ed](f05e8ed0ce))
* skip stock expense GL entries for non-stock items ([#57518](https://github.com/frappe/erpnext/issues/57518)) ([861c50e](861c50e727))
* **stock:** narrow legacy serial ledger lookup by item (backport [#57499](https://github.com/frappe/erpnext/issues/57499)) ([#57506](https://github.com/frappe/erpnext/issues/57506)) ([59efe72](59efe7299a))
* stop storing raw title template on subcontracting orders ([0863c1e](0863c1e05c))
* **subcontracting:** release raw-material reservation when closing a subcontracting order ([ee75fac](ee75fac9d7))
* **test:** flaky test in exchange rate revaluation ([484ff8e](484ff8e349))
* typeerror in get_batches_by_oldest for mixed batch expiry ([bbe7580](bbe7580c9d))
* update formatting of growth view for FS report ([da3844c](da3844c4df))
* update operating cost when propagating workstation hour rate to routing ([#57504](https://github.com/frappe/erpnext/issues/57504)) ([39d5fd8](39d5fd84db))
* use company currency instead of global default in report (backport [#56561](https://github.com/frappe/erpnext/issues/56561)) ([9e5f77b](9e5f77b57c))

### Features

* block sales invoice submit when customer overdue exceeds threshold (backport [#57230](https://github.com/frappe/erpnext/issues/57230), [#57298](https://github.com/frappe/erpnext/issues/57298)) ([#57438](https://github.com/frappe/erpnext/issues/57438)) ([d3c5e86](d3c5e866f0)), closes [#52960](https://github.com/frappe/erpnext/issues/52960)
* book Expenses Added To Stock GL entries (backport [#57190](https://github.com/frappe/erpnext/issues/57190) + [#57475](https://github.com/frappe/erpnext/issues/57475)) ([#57503](https://github.com/frappe/erpnext/issues/57503)) ([68caa60](68caa60dfa))
* make Shipping Rule Cost Center optional with company default fallback (backport [#57355](https://github.com/frappe/erpnext/issues/57355)) ([#57385](https://github.com/frappe/erpnext/issues/57385)) ([bbd942c](bbd942c600))
2026-07-28 23:31:26 +00:00
Diptanil Saha
eaf95e5c36 Merge pull request #57548 from frappe/version-16-hotfix
chore: release v16
2026-07-29 04:59:39 +05:30
Shllokkk
52f61c088e Merge pull request #57568 from frappe/mergify/bp/version-16-hotfix/pr-57566
fix(item): correct description on deferred revenue/expense (backport #57566)
2026-07-28 23:21:22 +05:30
mergify[bot]
9c946eb168 fix: recover failed POS closings (backport #57203) (#57572)
Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-07-28 22:58:24 +05:30
Mihir Kandoi
8f36753705 fix(manufacturing): fall back to UOM Conversion Factor in Production Plan (backport #57553) (#57554)
fix(manufacturing): fall back to UOM Conversion Factor in Production Plan

Production Plan read the conversion factor straight off the item's own
UOM child table, so an item with a purchase UOM but no matching row threw
"UOM Conversion factor not found" while Stock Entry silently resolved it
from the item's variant template or the UOM Conversion Factor doctype.
Resolve it the same way, and keep returning None when nothing is
configured anywhere so the missing-setup error still fires.
2026-07-28 15:02:05 +00:00
mergify[bot]
7a606ab91c fix: add permission check for get_item_details (backport #57515) (#57551)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-28 14:47:51 +00:00
Shllokkk
2471c5ccbb fix: bump item doctype modified timestamp so description change syncs on migrate 2026-07-28 19:40:10 +05:30
Shllokkk
0ce50407f9 fix: resolve backport conflicts in item doctype 2026-07-28 19:33:33 +05:30
Shllokkk
ee12f8d2d4 fix(item): correct description on deferred revenue/expense
(cherry picked from commit fa75aa08ab)

# Conflicts:
#	erpnext/stock/doctype/item/item.json
#	erpnext/stock/doctype/item/item.py
2026-07-28 13:52:59 +00:00
Khushi Rawat
c690ed0058 Merge pull request #57429 from frappe/mergify/bp/version-16-hotfix/pr-57382
fix: map MT940 per-transaction reference from :61: customer_reference (backport #57382)
2026-07-28 18:00:14 +05:30
pandiyan
b432a10222 refactor: reuse shared date range validation across reports 2026-07-28 17:29:40 +05:30
Shllokkk
ca657d2629 fix: respect child warehouse account override in Stock and Account Value Comparison (#57552)
fix: respect child warehouse account override in stock vs account value comparison
(cherry picked from commit 5fc20d6b8e)
2026-07-28 11:22:41 +00:00
Mihir Kandoi
083ef9baa7 Merge pull request #57542 from frappe/mergify/bp/version-16-hotfix/pr-57540
fix(setup): scope manufacturing warehouse filters to company (backport #57540)
2026-07-28 14:22:17 +05:30
Mihir Kandoi
f05e8ed0ce fix(setup): scope manufacturing warehouse filters to company
Default WIP, Finished Goods and Scrap Warehouse fields on Company listed
warehouses of every company. Filter them by the current company and
exclude group warehouses, matching the other warehouse fields.

(cherry picked from commit 632113c309)

# Conflicts:
#	erpnext/setup/doctype/company/company.js
2026-07-28 14:20:05 +05:30
Mihir Kandoi
feec193d7d Merge pull request #57536 from mihir-kandoi/backport-bom-creator-update-cost-v16
fix(manufacturing): update cost of BOMs created via BOM Creator (backport #57532)
2026-07-28 13:06:27 +05:30
rohitwaghchaure
861c50e727 fix: skip stock expense GL entries for non-stock items (#57518)
* fix: skip stock expense gl entries for non stock items

(cherry picked from commit 747f4df778dca45cf044c02f0e933d3b230b8334)

* test: use a leaf expense account for the service item invoice
2026-07-28 13:00:41 +05:30
Mihir Kandoi
f1a0a5e1bf fix(manufacturing): update cost of BOMs created via BOM Creator
`calculate_rm_cost` skipped rate refresh whenever `bom_creator` was set,
so neither the Update Cost button nor the BOM Update Tool could ever
refresh those BOMs. Every BOM in a multi-level tree carries the field, so
whole trees stayed frozen at their creation rates.

The guard replaced the removed `rm_cost_as_per == "Manual"` check in
0b63dbf, on the assumption that BOM Creator rows hold manual rates. They
do not: BOM Creator recomputes every row from `rm_cost_as_per` on save.
2026-07-28 12:49:21 +05:30
Mihir Kandoi
7eb0eb77aa Merge pull request #57529 from frappe/mergify/bp/version-16-hotfix/pr-57521
fix(manufacturing): sum semi-FG qty across split job cards (backport #57521)
2026-07-28 12:47:43 +05:30
Mihir Kandoi
ca15a14666 test: import make_job_card from work_order module on v16 2026-07-28 12:34:50 +05:30
Mihir Kandoi
afbe1f7e53 Merge pull request #57530 from frappe/mergify/bp/version-16-hotfix/pr-57528
fix(manufacturing): scope BOM Creator tree children to the parent row (backport #57528)
2026-07-28 12:30:24 +05:30
Mihir Kandoi
339bb0b4ea fix(manufacturing): scope BOM Creator tree children to the parent row
The BOM Creator tree identified a node by the parent's item code
(fg_item) instead of the specific BOM Creator Item row, so every
occurrence of a repeated sub-assembly shared one child set: expanding
any one of them listed the raw materials of all of them, and deleting
one wiped the raw materials of its siblings.

Key the tree on fg_reference_id and make the node value the row name,
matching the framework convention that a tree node's value is its
docname. Item code now travels as its own field for the label and for
the fg_item argument sent back on add/convert.

Fixes #57311

(cherry picked from commit b37152752f)
2026-07-28 06:49:59 +00:00
Diptanil Saha
fe6534b888 refactor(postgres): port point_of_sale get_items to the query builder (partial backport #56153) (#57527)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 12:17:20 +05:30
Mihir Kandoi
ebc8482310 fix(manufacturing): exclude corrective job cards from semi-FG aggregate
(cherry picked from commit bde118e7cf)
2026-07-28 06:44:37 +00:00
Mihir Kandoi
c411b8e471 fix(manufacturing): sum semi-FG qty across split job cards
update_semi_finished_good_details assigned the current job card's
manufactured_qty to Work Order.produced_qty instead of accumulating it,
so a second job card on the same operation overwrote the first. Nothing
corrected it afterwards because StatusService.update_work_order_qty
returns early for track_semi_finished_goods work orders, leaving the
work order stuck below its planned qty with no way to progress.

Aggregate manufactured_qty and completed_qty over the operation's
submitted job cards instead.

(cherry picked from commit 5548f0726a)
2026-07-28 06:44:37 +00:00
Mihir Kandoi
06ba783267 Merge pull request #57523 from aerele/backport-56561-version-16-hotfix
fix: use company currency instead of global default in report (backpo…
2026-07-28 12:13:00 +05:30
pandiyan
a30aac87bf fix: detect the currency column by fieldtype in trends total row
calculate_total_row tested each column with `"Link/Currency" in col`, but
based-on and group-by columns are dicts, so the test checked the dict's keys
and never matched. currency_col_idx stayed None and the grand-total row's
currency cell was left unset, so Total(Amt) rendered with the global default
currency instead of the company's.

Match the dict's fieldtype/options instead. Dict columns are never numeric
and string columns are never Link columns, so the two branches are now
mutually exclusive.
2026-07-28 11:28:07 +05:30
Mihir Kandoi
c7ffe822f1 Merge pull request #57517 from frappe/mergify/bp/version-16-hotfix/pr-57493
fix: stop storing raw title template on subcontracting orders (backport #57493)
2026-07-28 11:09:03 +05:30
pandiyan
9e5f77b57c fix: use company currency instead of global default in report (backport #56561)
Reports like Sales Order Trends and Purchase Order Trends showed the global
default currency symbol instead of the transacting company's currency.

Threads the company currency through conditions["company_currency"] in
trends.get_columns and uses it for both the chart's currency and the Total
row. The chart now skips the grand-total row by its label instead of by a
falsy first periodic cell, so the already-summed Total row is not added into
the datapoints a second time.

Backport of #56561 (frappe/erpnext). Tests from the original PR are not
included: the trends report test files do not exist on this branch.
2026-07-28 11:08:13 +05:30
pandiyan
0863c1e05c fix: stop storing raw title template on subcontracting orders
subcontracting order and subcontracting inward order carry a hidden
title field defaulting to "{supplier_name}" / "{customer_name}", while
their title_field points at supplier_name / customer_name. document.
set_title_field() substitutes the template only when title_field is
"title", so every record stores the placeholder verbatim.

drop the dead default and hidden flags, move title into the other info
tab to match purchase order, and add a patch to repair existing rows.

(cherry picked from commit 5008e6126f)
2026-07-28 05:12:49 +00:00
Shllokkk
39d5fd84db fix: update operating cost when propagating workstation hour rate to routing (#57504) 2026-07-28 10:26:47 +05:30
rohitwaghchaure
68caa60dfa feat: book Expenses Added To Stock GL entries (backport #57190 + #57475) (#57503)
* fix: exclude landed cost from purchase expense GL entries

* feat: book expenses added to stock GL entries for stock vouchers

* test: enable stock expense gl entries flag for purchase expense test
2026-07-27 18:32:13 +00:00
mergify[bot]
f9b3e42dcd fix(quotation): carry forward communications from opportunity (backport #57507) (#57509)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-27 18:14:11 +00:00
mergify[bot]
59efe7299a fix(stock): narrow legacy serial ledger lookup by item (backport #57499) (#57506)
fix(stock): narrow legacy serial ledger lookup by item (#57499)

Filter legacy Stock Ledger Entry lookups by item code so the existing
item and warehouse index can reduce rows scanned during serial valuation.

(cherry picked from commit 425191e57e)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-27 22:10:00 +05:30
mergify[bot]
3ea19d8eb1 fix(crm): clarify the reason why an opportunity cannot be declared as lost (backport #57495) (#57498)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-27 17:01:21 +02:00
ruthra kumar
1f9c4bc933 Merge pull request #57484 from ruthra-kumar/fix_flaky_err_test
fix(test): flaky test in exchange rate revaluation
2026-07-27 17:09:32 +05:30
ruthra kumar
484ff8e349 fix(test): flaky test in exchange rate revaluation
- remove redundant setup on system settings
2026-07-27 16:57:09 +05:30
mergify[bot]
ad9870acc1 fix(crm): align Opportunity status checks with Quotation statuses (backport #57489) (#57491)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-27 12:22:35 +02:00
Mihir Kandoi
8925585895 Merge pull request #57481 from frappe/mergify/bp/version-16-hotfix/pr-57463
fix(subcontracting): release raw-material reservation when closing a subcontracting order (backport #57463)
2026-07-27 15:01:03 +05:30
Mihir Kandoi
8d511c9b71 Merge pull request #57487 from frappe/mergify/bp/version-16-hotfix/pr-57485
fix: pool batch slot values on every run, not only when negative (backport #57485)
2026-07-27 14:04:17 +05:30
Mihir Kandoi
85925680ac test: assert batch pooling preserves the group total on a repeating rate
(cherry picked from commit 545262c5d4)
2026-07-27 08:24:15 +00:00
Mihir Kandoi
5763378ee1 fix: pool batch slot values on every run, not only when negative
A batch is one valuation pool, so any per-slot value difference within a
batch is stale detail from the report's own age slots, not real valuation.
The rebalance only ran when consumption had already driven a slot negative,
so a batch whose receipts landed at different rates kept a skewed split
across age buckets (one bucket free, another double-priced) while the total
stayed correct.

Drop the negative-slot precondition and always spread a batch's pooled value
over its slots in proportion to qty. Redistribution preserves group totals,
so buckets still sum to Stock Balance; only the split across ages changes.

(cherry picked from commit cedaaa3a00)
2026-07-27 08:24:15 +00:00
Lakshit Jain
c3eac77ee1 Merge pull request #56952 from frappe/mergify/bp/version-16-hotfix/pr-54855
refactor(financial-report): fix row transformation for growth calculations (backport #54855)
2026-07-27 13:31:55 +05:30
Sudharsanan11
008c3b145d test(subcontracting): cover reservation release on closing a subcontracting order
close a partially-received sco with a reserve warehouse and assert the
raw-material reservation is released and projected qty recovers.

(cherry picked from commit e4b8065a69)
2026-07-27 13:01:24 +05:30
Sudharsanan11
ee75fac9d7 fix(subcontracting): release raw-material reservation when closing a subcontracting order
the bin reserved-qty recalc filtered out closed purchase orders but not
closed subcontracting orders, so closing a partially-received sco kept the
reservation for the unreceived qty and left projected qty understated.
apply the same closed-status filter to the subcontracting order path.

(cherry picked from commit db91a79d31)

# Conflicts:
#	erpnext/stock/doctype/bin/bin.py
2026-07-27 13:01:24 +05:30
ruthra kumar
220d005cc2 Merge pull request #57478 from frappe/mergify/bp/version-16-hotfix/pr-57476
refactor: configurable date in reverse ERR journals (backport #57476)
2026-07-27 12:34:16 +05:30
ruthra kumar
ed1a58aa2a ci: debugging 2026-07-27 12:23:33 +05:30
ruthra kumar
2fd1dbd23a refactor(test): manually submit reverse err journal
(cherry picked from commit 1a558ce641)

# Conflicts:
#	erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py
2026-07-27 11:53:29 +05:30
ruthra kumar
68dcc96527 refactor: configurable date in reverse ERR journals
(cherry picked from commit 0be33e4132)
2026-07-27 06:11:10 +00:00
Shllokkk
708d889036 Merge pull request #57473 from frappe/mergify/bp/version-16-hotfix/pr-57443
fix: rename misleading filter labels in AR/AP reports (backport #57443)
2026-07-26 20:22:08 +05:30
Shllokkk
20e2728037 chore: resolve patches.txt conflict for backport 2026-07-26 19:21:01 +05:30
MochaMind
2359e1610a chore: update POT file (#57470) 2026-07-26 15:45:55 +02:00
Shllokkk
2e5fc38179 fix: migrate stored AR/AP ageing filter to renamed field
(cherry picked from commit f13cd00494)

# Conflicts:
#	erpnext/patches.txt
2026-07-26 11:47:26 +00:00
Shllokkk
6d856fa632 fix: rename misleading filter labels in AR/AP reports
(cherry picked from commit e99425b7c4)
2026-07-26 11:47:26 +00:00
Shllokkk
1802c75ed6 Merge pull request #57468 from frappe/mergify/bp/version-16-hotfix/pr-57466
fix: recalculate operating cost on hour rate change in routing (backport #57466)
2026-07-26 16:27:11 +05:30
Shllokkk
c182b4085b fix: recalculate operating cost on hour rate change in routing
(cherry picked from commit 598f6f0f4e)
2026-07-26 08:37:06 +00:00
mergify[bot]
a0af717234 fix: enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (backport #57458) (#57460)
fix: enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (#57458)

(cherry picked from commit 4e8f5de5cb)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-25 08:27:06 +05:30
Jatin3128
d3c5e866f0 feat: block sales invoice submit when customer overdue exceeds threshold (backport #57230, #57298) (#57438)
* feat: block sales invoice submit when customer overdue exceeds threshold (#57230)

* feat: block sales invoice submit when customer overdue exceeds threshold

Adds an opt-in, per-customer Overdue Billing Threshold. When enabled in
Accounts Settings, submitting a Sales Invoice is blocked if the customer's
overdue amount exceeds their threshold, unless the current user holds a
configured bypass role. Modeled on the existing credit limit feature.

- Accounts Settings (Credit Limits tab): enable toggle + bypass role.
- Per-customer threshold on the Customer Credit Limit table, shown only
  when the feature is enabled via a property setter (same mechanism as
  subscription / accounting dimension sections). Table relabeled to
  "Credit & Overdue Limits".
- Overdue is read live from the ledger via get_outstanding_invoices
  (payments already netted), summing Sales Invoices past their due date.
- Enforced in Sales Invoice on_submit, after the credit-limit check;
  returns are exempt.
- validate_credit_limit_on_change no longer trips when a row sets only
  the overdue threshold (credit_limit = 0).

Fixes #52960

* fix: compute overdue amount in company currency and format with fmt_money

get_customer_overdue_amount now sums GL Entry debit - credit grouped per
invoice, which is always booked in company currency, instead of using
get_outstanding_invoices which returns the receivable-account currency.
The threshold is in company currency, so the previous comparison could mix
currencies for customers with a foreign-currency receivable account. This
mirrors how get_customer_outstanding computes the figure for the existing
credit-limit check.

The blocking message now formats both amounts with fmt_money using the
company currency.

Adds a test asserting a 100 USD invoice at a conversion rate of 50 is
counted as 5000 in company currency.

* refactor: drop redundant threshold coercion and dead test cleanup

- Coerce the overdue threshold with flt() once when reading it, instead of
  calling flt() on it at each of the three use sites.
- Remove a no-op set_overdue_billing_threshold() call in the feature-disabled
  block (the threshold was already set to that value) and the trailing reset,
  which is dead since each test is rolled back.

No behaviour change.

* fix: compute overdue amount from payment terms, matching the Overdue status

The overdue amount keyed on Sales Invoice.due_date, which set_due_date() sets
to the LAST payment term. An invoice whose first term was past due and unpaid
was therefore counted as zero, even though ERPNext already shows it as Overdue
in the invoice list. The gate and the UI could disagree.

get_customer_overdue_amount now follows the same rule as is_overdue(): per
invoice, the amount that has fallen due (sum of payment schedule terms past
their due date) minus what has been paid, clamped to the outstanding balance.
Invoices without a schedule (POS, opening) still fall back to the invoice due
date, mirroring is_overdue()'s own guard.

The ledger stays the source of truth for what is unpaid: the outstanding per
invoice is still SUM(debit) - SUM(credit) from GL Entry. base_payment_amount is
always stored in company currency, so no currency conversion is needed and the
comparison against the threshold stays consistent.

Adds a test covering a two-term invoice: only the past-due term counts, and
paying it off clears the overdue amount.

* feat: honour the overdue billing threshold set on the customer group

The threshold lives on Customer Credit Limit, which is also rendered on
Customer Group. A threshold set there was stored but never evaluated, so the
configuration was a silent no-op.

get_overdue_billing_threshold now reads the customer's row and falls back to
its customer group, mirroring get_credit_limit. The group's
bypass_credit_limit_check is deliberately not consulted: it is labelled for the
credit limit check at sales order and is unrelated to overdue billing.

get_customer_group_details also dropped the threshold when copying group rows
onto a customer, because it copied a single hardcoded field per table. It now
copies a list of fields per table, so credit_limit and overdue_billing_threshold
both carry over.

* refactor: clearer labels for the overdue billing control (#57298)

refactor: clearer labels and messages, drop "threshold" wording

User-facing text only, no field or behaviour changes:

- Accounts Settings toggle label -> "Restrict Customer Over Billing".
- Bypass role label -> "Role Allowed to Bypass Over Billing Restriction".
- Customer Credit Limit field label -> "Overdue Limit".
- Rewrote the descriptions and the block message to match and to stop
  saying "threshold".

* fix: treat zero overdue limit as opt-out and isolate settings in test

get_overdue_billing_threshold treated an explicit 0 on the customer's credit
limit row as "not set" and fell back to the customer group. A customer could
not be exempted from the group restriction while keeping a credit limit row,
and every existing row defaults to 0, so enabling the feature on a group blocked
all its customers that had any credit limit row. Guard the group fallback on
"threshold is None" (no row for the company) instead of a falsy check, so an
explicit 0 acts as an opt-out.

test_overdue_billing_threshold_on_submit mutated the Accounts Settings singleton
without restoring it, so a failed assertion mid-test leaked
enable_overdue_billing_threshold and the bypass role into later tests that submit
sales invoices. Wrap the mutations in try/finally and restore the originals.

* fix: let a zero customer overdue limit inherit the group's limit

A 0 on the customer's credit limit row falls back to the customer group again;
only a non-zero value on the customer overrides the group. Reverts the earlier
opt-out interpretation and updates the fallback test to expect the group's limit.
2026-07-24 15:33:26 +05:30
Mihir Kandoi
2b8b6a09dd Merge pull request #57440 from frappe/mergify/bp/version-16-hotfix/pr-57435
fix(accounts): respect user permissions in party dashboard company list (backport #57435)
2026-07-24 15:12:35 +05:30
pandiyan
c79f2e45a8 fix: respect user permissions in party dashboard company list
use frappe.get_list instead of frappe.get_all in get_dashboard_info so
the company list honors user permissions. previously, a party with
invoices across multiple companies would raise "User don't have
permissions to select/read this account" for users restricted to a
subset of companies, since get_party_account was called for companies
the user could not access.

fixes frappe/erpnext#57428

(cherry picked from commit 903c87bcaa)
2026-07-24 09:30:41 +00:00
Mihir Kandoi
ab4b5abb91 Merge pull request #57420 from aerele/backport-57412-customer-pick-list-v16
fix: map pick list customer to delivery note when no sales order
2026-07-24 14:58:23 +05:30
pandiyan
3f6501b4ff fix: map pick list customer to delivery note when no sales order
backport of #57412
2026-07-24 11:12:17 +05:30
srujan00123
b9c4f790bd fix: map MT940 per-transaction reference from :61: customer_reference
The mt940 library exposes ``transaction_reference`` from the :20: tag,
which is the statement-level reference and identical for every
transaction in a statement. The bank-statement-to-CSV conversion was
using it verbatim, so every imported row ended up with the same
reference, making reconciliation impossible.

Read the per-transaction reference from ``customer_reference`` on the
:61: tag instead. Handle two edge cases:

- **Overflow >16 chars.** When a bank emits a single-line :61: whose
  reference exceeds 16 characters, the mt940 regex splits the tail into
  ``extra_details``. Gate the rejoin to cases where
  ``customer_reference`` is exactly at the 16-char MT940 cap; below
  that, ``extra_details`` is genuine supplementary information and must
  not be appended.
- **``NONREF`` sentinel.** The MT940 standard marker for "no customer
  reference". Check it against the un-concatenated value so that a
  ``NONREF`` customer reference with populated ``extra_details`` still
  falls back to ``bank_reference`` instead of returning a junk
  ``NONREFsomething`` value.

Also switch the Description column to ``transaction_details`` (the :86:
tag content) so rows carry their real narrative instead of the mostly
empty :61: supplementary field.

(cherry picked from commit 551d709c64)
2026-07-24 05:07:41 +00:00
Mihir Kandoi
2206e9fe2a Merge pull request #57415 from frappe/mergify/bp/version-16-hotfix/pr-57413
fix: typeerror in get_batches_by_oldest for mixed batch expiry (backport #57413)
2026-07-23 18:38:47 +05:30
pandiyan
bbe7580c9d fix: typeerror in get_batches_by_oldest for mixed batch expiry
sort on (expiry is none, expiry) so a null expiry_date is never
order-compared against a datetime.date, which raised typeerror in
python 3 when a warehouse held both dated and never-expiring batches.

(cherry picked from commit 62c9f8ee3e)
2026-07-23 12:53:51 +00:00
Mihir Kandoi
ef21a9ba9b Merge pull request #57250 from frappe/mergify/bp/version-16-hotfix/pr-57245 2026-07-23 16:25:43 +05:30
Mihir Kandoi
88f9039d1a Merge pull request #57408 from frappe/mergify/bp/version-16-hotfix/pr-57400
fix: guard against missing is_your_company_address custom field on ad… (backport #57400)
2026-07-23 16:21:06 +05:30
pandiyan
2e6b4b5838 fix: guard against missing is_your_company_address custom field on address
(cherry picked from commit ea3ed8b836)
2026-07-23 10:41:18 +00:00
Mihir Kandoi
85d793b690 Merge pull request #57404 from mihir-kandoi/fix-stock-ageing-batch-pool-rebalance-v16
fix: rebalance batch slot values at the pooled rate when driven negative (backport #57403)
2026-07-23 16:09:11 +05:30
Mihir Kandoi
8a50572786 fix: rebalance batch slot values at the pooled rate when driven negative
A batch is one valuation pool, so consumption is valued at the pooled
rate while slots may carry stale intra-batch detail (e.g. units
reconciled at zero and later merged). Consuming such a slot leaves a
negative value on positive qty. Spread the pool value across the
batch's slots when that happens; non-batchwise slots pool per
warehouse.
2026-07-23 15:57:13 +05:30
mergify[bot]
bbd942c600 feat: make Shipping Rule Cost Center optional with company default fallback (backport #57355) (#57385)
feat: make Shipping Rule Cost Center optional with company default fallback (#57355)

feat(shipping-rule): make cost center optional with company default fallback

Cost Center on Shipping Rule is no longer mandatory. When left blank, the
applied shipping tax row falls back to the company default cost center,
avoiding the 'Cost Center is required for Profit and Loss account' error on
submit. The rule's project is also applied to the tax row.

(cherry picked from commit a47f25896b)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-23 15:42:28 +05:30
mergify[bot]
84813d7f46 fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime (backport #57380) (#57397)
* fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime  (#57380)

* fix: shift same-timestamp sibling SLEs when cancelling an entry

update_qty_in_future_sle compared against the reversal SLE's own
creation and skipped same-posting_datetime siblings on cancel, leaving
their qty_after_transaction stale and causing false negative stock
errors.

* fix: revert update_qty_in_future_sle cancel tie-break, it double-counted

(cherry picked from commit 8c0ec3c179)

# Conflicts:
#	erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py

* chore: fix conflicts

Fix test case for cancelling stock ledger entries with the same timestamp to ensure correct behavior.

* chore: fix conflicts

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-23 10:08:20 +00:00
Shllokkk
1132eb1a0f fix: respect selected BOM when creating work order for variant item (#57359)
* fix: respect selected BOM when creating work order for variant item

* fix: add type hints to make_work_order
2026-07-23 13:48:45 +05:30
kaulith
2323f2c978 Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-57245 2026-07-23 11:59:44 +05:30
Mihir Kandoi
e277647f7b Merge pull request #57362 from frappe/mergify/bp/version-16-hotfix/pr-57361
refactor: move new-doc route options to StockController (backport #57361)
2026-07-22 18:04:50 +05:30
Mihir Kandoi
c587f4934a refactor: move new-doc route options to StockController
set_route_options_for_new_doc lived in TransactionController, so doctypes
extending StockController directly (Stock Reconciliation, Stock Entry) missed
the Batch/SABB prefill or duplicated it locally. Move it to StockController
and call it from onload_post_render so all descendants inherit it.

- Batch quick entry from Stock Reconciliation items now prefills Item
- SABB route options unified: warehouse || s_warehouse || t_warehouse,
  so transaction doctypes now also prefill warehouse
- Stock Entry's duplicate handler removed; its onload_post_render now
  calls super

(cherry picked from commit 551559e804)
2026-07-22 12:33:46 +00:00
Mihir Kandoi
e0020b478a Merge pull request #57354 from mihir-kandoi/fix/reserved-batch-precision-v16
fix: get reserved batch qty precision from settings (v16)
2026-07-22 15:11:23 +05:30
Mihir Kandoi
95de2374ed fix: get reserved batch qty precision from settings 2026-07-22 14:58:11 +05:30
Frappe PR Bot
a5de60c357 chore(release): Bumped to Version 16.29.0
# [16.29.0](https://github.com/frappe/erpnext/compare/v16.28.0...v16.29.0) (2026-07-22)

### Bug Fixes

* add fetch from in production plan material request child table ([2852671](2852671cd5))
* added missing validations for `Dunning Type` (backport [#57224](https://github.com/frappe/erpnext/issues/57224)) ([#57227](https://github.com/frappe/erpnext/issues/57227)) ([3948951](3948951904))
* allow delivery when a batch is reserved across multiple sales orders (backport [#57169](https://github.com/frappe/erpnext/issues/57169)) ([56bbca0](56bbca0203))
* block changing Stock account type when stock ledger entries exist (backport [#57283](https://github.com/frappe/erpnext/issues/57283)) ([#57285](https://github.com/frappe/erpnext/issues/57285)) ([a26296c](a26296ca50))
* consider min order qty in the purchase/transfer flow of production plan (backport [#57204](https://github.com/frappe/erpnext/issues/57204)) ([cbd4f93](cbd4f93f3d))
* correct typo in allow_negative_stock parameter ([e2be05e](e2be05e48b))
* dont overwrite rate with 0 if not found ([aa08f75](aa08f753b4))
* exclude fully-delivered reservations from the conflict message ([555c607](555c607f2f))
* exclude transferred_qty from work order item to pick list item mapping ([c466b49](c466b49d09)), closes [#57253](https://github.com/frappe/erpnext/issues/57253) [#57236](https://github.com/frappe/erpnext/issues/57236) [#56596](https://github.com/frappe/erpnext/issues/56596)
* hide job card field in purchase order item ([20f6ac8](20f6ac81b9))
* keep Standard Cost stock value in step with the standard rate ([fe65882](fe65882e59))
* name every conflicting voucher in the reserved batch error ([839fd5e](839fd5e3b6))
* parallel reposting stalls between scheduler ticks (backport [#57220](https://github.com/frappe/erpnext/issues/57220)) ([#57248](https://github.com/frappe/erpnext/issues/57248)) ([a3bfded](a3bfdede06))
* pass ctx keys get_price_list_rate_for reads, skip rate update on insert ([c3aea9c](c3aea9ca9c))
* **payments:** ensure `payments` app installed on the site in `payment_app_import_guard` (backport [#57342](https://github.com/frappe/erpnext/issues/57342)) ([#57344](https://github.com/frappe/erpnext/issues/57344)) ([2c8c076](2c8c076f6e))
* permission issue (backport [#57112](https://github.com/frappe/erpnext/issues/57112)) (backport [#57142](https://github.com/frappe/erpnext/issues/57142)) ([#57184](https://github.com/frappe/erpnext/issues/57184)) ([0817d10](0817d1064c))
* project % complete field allowing modification when manual method (backport [#57274](https://github.com/frappe/erpnext/issues/57274)) ([#57276](https://github.com/frappe/erpnext/issues/57276)) ([0d53f1a](0d53f1adb7))
* **project:** improved access control for project users (backport [#56675](https://github.com/frappe/erpnext/issues/56675)) ([#57181](https://github.com/frappe/erpnext/issues/57181)) ([b1adec7](b1adec7e9e))
* **report:** handle nonetype error in timesheet billing summary grouping logic ([b7cf3bf](b7cf3bf641))
* rescale batch FIFO slot values on stock reconciliation ([6b3b03f](6b3b03fcd8))
* rescale stock ageing FIFO slot values on stock reconciliation ([c9394c0](c9394c030f))
* resolve float precision before streaming stock ledger entries ([0f25254](0f252542c3))
* restrict jinja globals in process statement of accounts templates (backport [#56458](https://github.com/frappe/erpnext/issues/56458)) ([#57232](https://github.com/frappe/erpnext/issues/57232)) ([5f6952b](5f6952b15c))
* revalue batch reco slots only when the entry covers the full batch ([a4bf506](a4bf50656a))
* round outstanding qty guard consistently with the conflict gate ([f3e1b3f](f3e1b3fca7))
* set correct currency in supplier quotation net rate field ([e24ef84](e24ef847dc))
* show transaction currency symbol in Payment Request schedule dialog and reference table (backport [#57050](https://github.com/frappe/erpnext/issues/57050)) ([#57312](https://github.com/frappe/erpnext/issues/57312)) ([429b58b](429b58b833))
* **stock:** correct stock ageing value for moving average and lifo items ([#56693](https://github.com/frappe/erpnext/issues/56693)) ([02549ef](02549ef8c4))
* strip account number when building account name in COA importer ([e1ebfa7](e1ebfa7163))
* sync process loss percentage when fg qty changes ([beeffee](beeffee8f9))
* sync translations from crowdin ([#57187](https://github.com/frappe/erpnext/issues/57187)) ([827831a](827831a247))
* use system float precision for batch qty comparison ([92217b2](92217b2c45))
* validate buying price list on material request and update item rates on change ([025b2f2](025b2f2922))

### Features

* add on hold status to project ([57a49ce](57a49ce168))
* recalculate valuation rate and stock value from Bin ([a38cbfc](a38cbfc88e))
* **stock:** automatically link portal users to their associated contact profiles for customers and suppliers ([134d63d](134d63de78))
* **stock:** expose all Bin qty fields in Stock Summary and Stock Projected Qty ([0bdf258](0bdf258888))
2026-07-22 00:17:03 +00:00
Diptanil Saha
264bfa188b Merge pull request #57324 from frappe/version-16-hotfix
chore: release v16
2026-07-22 05:45:21 +05:30
mergify[bot]
2c8c076f6e fix(payments): ensure payments app installed on the site in payment_app_import_guard (backport #57342) (#57344)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-21 23:12:53 +00:00
mergify[bot]
a0ac2a58dd chore: remove apiclient (backport #57339) (#57341)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-21 20:26:20 +00:00
Shllokkk
5930ce623c Merge pull request #57321 from Shllokkk/sync-process-loss-percentage-v16
fix: sync process loss percentage when fg qty changes
2026-07-21 16:24:52 +05:30
Mihir Kandoi
3a196ae8e7 Merge pull request #57317 from mihir-kandoi/fix-stock-ageing-reco-revaluation-v16
fix: rescale stock ageing FIFO slot values on stock reconciliation (backport #57316)
2026-07-21 15:46:22 +05:30
Shllokkk
beeffee8f9 fix: sync process loss percentage when fg qty changes 2026-07-21 15:11:10 +05:30
Mihir Kandoi
0f252542c3 fix: resolve float precision before streaming stock ledger entries
get_single_value inside _revalue_reconciled_batch_slots runs while
rows stream through the unbuffered cursor on MariaDB, killing the
active iterator. Resolve it once in generate() with the other
prefetches.
2026-07-21 14:34:12 +05:30
Mihir Kandoi
92217b2c45 fix: use system float precision for batch qty comparison 2026-07-21 14:18:24 +05:30
Mihir Kandoi
a4bf50656a fix: revalue batch reco slots only when the entry covers the full batch
stock_value_difference / qty equals the new batch rate only when the
reco entry carries the entire batch, as the split out/in reco SLEs and
batches reconciled from zero do. Partial direct-batch_no entries mix a
qty delta with existing stock, so their slots keep prior values.

Plain items need no such guard: the valuation engine collapses the
FIFO stack to qty_after * valuation_rate on every reconciliation, so
rescaling remaining slots at the reco rate matches the ledger. Lock
that with a test.
2026-07-21 14:09:33 +05:30
Mihir Kandoi
6b3b03fcd8 fix: rescale batch FIFO slot values on stock reconciliation
Batch items take the batch-slot path, which mirrors the same value
arithmetic: the reco's incoming entry dumps the revaluation remainder
on one slot. Rescale each reconciled batch's slots at its post-reco
rate (stock_value_difference / qty of the incoming bundle entry).
2026-07-21 13:49:41 +05:30
Mihir Kandoi
c9394c030f fix: rescale stock ageing FIFO slot values on stock reconciliation
A reconciliation's stock_value_difference includes the revaluation of
stock already in the FIFO queue, but the whole amount was attached to
the qty-delta slot while older slots kept pre-revaluation values. A
downward revaluation therefore produced negative bucket values in the
Stock Ageing report, and repeated recos let the queue total drift away
from Stock Balance.

Re-derive every slot value as qty * valuation_rate after processing a
reco SLE, since a reconciliation values the entire balance at its rate.
Covers both single-SLE recos and the zero-out/re-add pair that flows
through the transfer bucket.
2026-07-21 13:45:02 +05:30
mergify[bot]
429b58b833 fix: show transaction currency symbol in Payment Request schedule dialog and reference table (backport #57050) (#57312)
fix: show transaction currency symbol in Payment Request schedule dialog and reference table (#57050)

* fix: show transaction currency symbol in Payment Request schedule dialog and reference table

When company currency (INR) differs from customer currency (USD), the Amount
column in the Select Payment Schedule dialog and the Payment Reference table on
the Payment Request form incorrectly displayed the company currency symbol (₹)
instead of the transaction currency symbol ($).

- Pass `currency` from the parent document on each schedule row returned by
  `get_available_payment_schedules` so the dialog can resolve the symbol.
- Add a hidden `currency` field to the dialog table and set `options: "currency"`
  on `payment_amount` so Frappe renders the correct symbol.
- Propagate `currency` into Payment Reference rows in `set_payment_references`.
- Add a hidden `currency` Link field to the Payment Reference child DocType and
  set `options: "currency"` on its `amount` field so the table renders correctly.



* fix: preserve currency when serializing payment schedule rows

get_available_payment_schedules set `schedule.currency` directly on
the Payment Schedule Document row, but `currency` isn't a field on
that DocType, so the API response serializer stripped it before it
reached the client. The Select Payment Schedule dialog and the
Payment Reference table therefore always fell back to the company
currency symbol, even with the earlier options="currency" changes in
place.

Convert each row to a plain dict via as_dict() first, then set the
currency key on the dict so it survives serialization.

* refactor: source schedule currency in dialog instead of API serializer

get_available_payment_schedules had to convert each child row with
as_dict() and re-attach currency, because currency is not a field on
Payment Schedule and the response serializer drops attributes set on the
Document itself.

The schedule dialog already has the transaction currency on frm.doc, so
set it there and let the API keep returning the schedule rows unchanged.
Payment Reference still stores currency per row.

---------




(cherry picked from commit 83e04dd773)

Co-authored-by: Henil Maru <henil@frappe.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-21 12:59:50 +05:30
mergify[bot]
e0d0bf07c8 refactor: rework appointment booking lifecycle and portal verification (backport #57270) (#57295)
* refactor: rework appointment booking lifecycle and portal verification (#57270)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 73004c6e4b)

# Conflicts:
#	erpnext/crm/doctype/appointment/test_appointment.py
#	erpnext/crm/doctype/appointment_booking_settings/test_appointment_booking_settings.py
#	erpnext/www/book_appointment/index.py

* chore: resolve conflicts

* fix: parse contact as native JSON in create_appointment

version-16-hotfix never received develop's 9955adb2fc, so the backported
tests calling create_appointment with a dict contact crashed on
json.loads. Use frappe.parse_json to accept both str and dict, matching
develop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:17:01 +05:30
mergify[bot]
a26296ca50 fix: block changing Stock account type when stock ledger entries exist (backport #57283) (#57285)
fix: block changing Stock account type when stock ledger entries exist (#57283)

(cherry picked from commit 4cdaa8dba6)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-20 22:58:22 +05:30
Mihir Kandoi
8a320fc0af Merge pull request #57302 from frappe/mergify/bp/version-16-hotfix/pr-57300
feat: recalculate valuation rate and stock value from Bin (backport #57300)
2026-07-20 20:40:02 +05:30
Mihir Kandoi
f5bb4052fa Merge pull request #57304 from frappe/mergify/bp/version-16-hotfix/pr-57301
feat(stock): expose all Bin qty fields in Stock Summary and Stock Projected Qty (backport #57301)
2026-07-20 20:39:47 +05:30
Mihir Kandoi
0bdf258888 feat(stock): expose all Bin qty fields in Stock Summary and Stock Projected Qty
Stock Summary's sort selector only offered 5 of Bin's 10 qty fields; add
the rest (ordered, requested, planned, reserved for production plan,
reserved stock) and extend get_data's or_filters so bins whose only
nonzero qty is one of the new fields show up when sorted by it. Sort
labels now mirror Bin field labels.

Stock Projected Qty report had a column for every Bin qty field except
reserved_stock; add it.

(cherry picked from commit 59c0c15c2e)
2026-07-20 14:48:22 +00:00
Mihir Kandoi
fe65882e59 fix: keep Standard Cost stock value in step with the standard rate
Mirrors update_qty's Standard Cost handling and drops fixed test item
names so reruns start from fresh SLE-less items.

(cherry picked from commit 49a43aad81)
2026-07-20 14:40:41 +00:00
Mihir Kandoi
a38cbfc88e feat: recalculate valuation rate and stock value from Bin
Renames the Recalculate Bin Qty button to Recalculate Values and sets
valuation_rate and stock_value from the last SLE (0 when none exists).

(cherry picked from commit df79e85f53)
2026-07-20 14:40:41 +00:00
Mihir Kandoi
942a8db7fa Merge pull request #57297 from frappe/mergify/bp/version-16-hotfix/pr-57280
fix(report): handle nonetype error in timesheet billing summary group… (backport #57280)
2026-07-20 17:54:31 +05:30
Poovetha
b7cf3bf641 fix(report): handle nonetype error in timesheet billing summary grouping logic
(cherry picked from commit 9a7209e668)
2026-07-20 12:14:13 +00:00
mergify[bot]
0d53f1adb7 fix: project % complete field allowing modification when manual method (backport #57274) (#57276)
Co-authored-by: nishkagosalia <nishka.gosalia@gmail.com>
2026-07-20 17:28:39 +05:30
Mihir Kandoi
fcee4f128e Merge pull request #57282 from frappe/mergify/bp/version-16-hotfix/pr-57273
fix: correct typo in allow_negative_stock parameter (backport #57273)
2026-07-20 12:43:53 +05:30
Afsal Syed
e2be05e48b fix: correct typo in allow_negative_stock parameter
(cherry picked from commit b3a616c328)
2026-07-20 07:01:53 +00:00
MochaMind
a9c3a00dc4 chore: update POT file (#57268) 2026-07-19 14:50:42 +02:00
kaulith
d70e005412 Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-57245 2026-07-19 11:38:25 +05:30
Mihir Kandoi
50c79c82bb Merge pull request #57257 from aerele/fix-pick-list-work-order-transferred-qty-leak-v16
fix: exclude transferred_qty from work order item to pick list item m…
2026-07-18 16:52:35 +05:30
pandiyan
c466b49d09 fix: exclude transferred_qty from work order item to pick list item mapping
get_mapped_doc copies same-named fields by default. work order item's
transferred_qty (cumulative across the whole work order) was leaking into
the new pick list item's transferred_qty (meant to track how much of
that pick list row has been converted into a stock entry, starting at 0).

the leaked value then got subtracted again in
get_pending_transfer_stock_qty(), so every pick list after the first
under-transferred raw materials by whatever was already recorded on the
work order, driving material_transferred_for_manufacturing towards zero
across repeated partial pick-list/finish cycles.

backport of #57253

fixes #57236, related to #56596
2026-07-18 16:42:40 +05:30
Kaushal Shriwas
32baf6a47c fix: force-delete repost data file during cleanup (backport #57245) 2026-07-17 22:52:30 +05:30
Mihir Kandoi
406d3b3862 Merge pull request #57252 from frappe/mergify/bp/version-16-hotfix/pr-57249
fix: add fetch from in production plan material request child table (backport #57249)
2026-07-17 22:32:49 +05:30
Mihir Kandoi
2852671cd5 fix: add fetch from in production plan material request child table
(cherry picked from commit dfc2a411e1)
2026-07-17 16:50:49 +00:00
rohitwaghchaure
a3bfdede06 fix: parallel reposting stalls between scheduler ticks (backport #57220) (#57248)
fix: parallel reposting stalls between scheduler ticks (#57220)

(cherry picked from commit 40f861c0a0)
2026-07-17 22:17:47 +05:30
Mihir Kandoi
34a65e5dba Merge pull request #57247 from frappe/mergify/bp/version-16-hotfix/pr-57241
fix: validate buying price list on material request and update item rates on change (backport #57241)
2026-07-17 21:49:56 +05:30
Mihir Kandoi
ec0da0f113 chore: remove unneccessary flt
(cherry picked from commit 3a63f61832)
2026-07-17 16:09:12 +00:00
Mihir Kandoi
aa08f753b4 fix: dont overwrite rate with 0 if not found
(cherry picked from commit 1ef3cd1d3f)
2026-07-17 16:09:12 +00:00
Mihir Kandoi
c3aea9ca9c fix: pass ctx keys get_price_list_rate_for reads, skip rate update on insert
update_item_rates passed price_not_uom_dependent, a key
get_price_list_rate_for never reads, and omitted conversion_factor, so a
stock-UOM price was never converted to the row UOM. The function's
(historically misnamed) price_list_uom_dependant ctx key carries the
Price List's price_not_uom_dependent value: truthy returns the found
rate as-is, falsy multiplies by conversion_factor.

Also guard on_update with is_new(): has_value_changed returns True when
there is no doc_before_save, so every first save re-wrote item rates.

(cherry picked from commit 6dcc0cab3a)
2026-07-17 16:09:11 +00:00
Mihir Kandoi
025b2f2922 fix: validate buying price list on material request and update item rates on change
(cherry picked from commit 18b15f2ca9)
2026-07-17 16:09:11 +00:00
mergify[bot]
5f6952b15c fix: restrict jinja globals in process statement of accounts templates (backport #56458) (#57232)
fix: restrict jinja globals in process statement of accounts templates

(cherry picked from commit ecb6d48ec0)

Co-authored-by: Shllokkk <shllokosan23@gmail.com>
2026-07-17 08:20:41 +00:00
mergify[bot]
3948951904 fix: added missing validations for Dunning Type (backport #57224) (#57227)
* fix: added missing validations for `Dunning Type`

(cherry picked from commit c8e7674d63)

* test: added tests for `Dunning Type` validation

(cherry picked from commit 2325068b19)

# Conflicts:
#	erpnext/accounts/doctype/dunning_type/test_dunning_type.py

* chore: resolve conflict

---------

Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-07-17 06:57:37 +00:00
Shllokkk
915562c7a8 Merge pull request #57219 from frappe/mergify/bp/version-16-hotfix/pr-57198
fix: strip account number when building account name in COA importer (backport #57198)
2026-07-17 11:39:00 +05:30
Mihir Kandoi
dfd05caeb0 Merge pull request #57017 from frappe/mergify/bp/version-16-hotfix/pr-56693
fix(stock): correct stock ageing value for moving average and lifo items (backport #56693)
2026-07-17 07:49:09 +05:30
MochaMind
827831a247 fix: sync translations from crowdin (#57187) 2026-07-16 18:00:39 +02:00
Shllokkk
e1ebfa7163 fix: strip account number when building account name in COA importer
(cherry picked from commit cbe406ee2a)
2026-07-16 15:21:50 +00:00
mergify[bot]
c055de2da6 refactor(dunning): converted get_dunning_letter_text to doc method and restrict_globals on render_template (backport #57205) (#57214)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-16 11:33:41 +00:00
Mihir Kandoi
c5d94d5fc3 Merge pull request #57211 from frappe/mergify/bp/version-16-hotfix/pr-57179
feat(stock): automatically link portal users to their associated contact profiles for customers and suppliers (backport #57179)
2026-07-16 16:29:56 +05:30
Afsal Syed
74451366c2 test(stock): add portal user contact link verification for customer and supplier
(cherry picked from commit 9ae2069bd9)
2026-07-16 15:46:06 +05:30
Afsal Syed
134d63de78 feat(stock): automatically link portal users to their associated contact profiles for customers and suppliers
(cherry picked from commit 337a06dfb6)
2026-07-16 15:44:45 +05:30
Mihir Kandoi
8946fc41a2 Merge pull request #57208 from mihir-kandoi/fix-production-plan-min-order-qty-v16
fix: consider min order qty in the purchase/transfer flow of production plan (backport #57204)
2026-07-16 13:46:23 +05:30
Mihir Kandoi
cbd4f93f3d fix: consider min order qty in the purchase/transfer flow of production plan (backport #57204)
The transfer flow ignored Consider Minimum Order Qty twice: the JS
handler force-reset the checkbox before fetching items, and the
purchase remainder left after allocating transfers from other
warehouses was never raised to min_order_qty (the check runs on the
total requirement before the split).

Drop the JS reset and apply min order qty to the purchase remainder,
in stock UOM before the purchase UOM conversion.
2026-07-16 13:36:04 +05:30
mergify[bot]
0817d1064c fix: permission issue (backport #57112) (backport #57142) (#57184)
* fix: permission issue (backport #57112) (#57142)

* fix: permission issue (#57112)

(cherry picked from commit 1fd2faa68d)

# Conflicts:
#	erpnext/controllers/stock_controller.py

* chore: fix conflicts

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
(cherry picked from commit 6b23b007a4)

# Conflicts:
#	erpnext/controllers/stock_controller.py

* chore: fix conflicts

* chore: fix conflicts

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-15 15:05:17 +05:30
mergify[bot]
b1adec7e9e fix(project): improved access control for project users (backport #56675) (#57181)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-15 14:19:07 +05:30
mergify[bot]
caea21208e Revert "chore: remove unused whitelisted method from project" (backport #56660) (#57178)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-15 07:09:36 +00:00
Mihir Kandoi
2808b34d42 Merge pull request #57170 from mihir-kandoi/backport-57169-version-16-hotfix
fix: allow delivery when a batch is reserved across multiple sales orders (backport #57169)
2026-07-15 12:31:39 +05:30
Mihir Kandoi
7ccb2584c4 test: set delivered batch on the row explicitly on v16
The v16 reserved-stock mapper attaches neither a bundle nor row
serial/batch fields when use_serial_batch_fields is enabled, and
update_stock_reservation_entries crashes on the missing bundle (fixed
on develop by 9c5f9218b5, not backported). Deliver via an explicit row
batch_no like the guard test so the bundle is built from row fields
before the reservation update runs.
2026-07-15 12:19:21 +05:30
Mihir Kandoi
f3e1b3fca7 fix: round outstanding qty guard consistently with the conflict gate 2026-07-15 12:00:02 +05:30
Mihir Kandoi
555c607f2f fix: exclude fully-delivered reservations from the conflict message 2026-07-15 12:00:02 +05:30
Mihir Kandoi
7cd7e4ab0f test: deliver reserved batches via bundles on v16
use_serial_batch_fields delivery of reserved stock crashes on v16 with
'Serial and Batch Bundle None not found' (fixed on develop only), so
deliver through auto-created bundles like test_auto_reserve_serial_and_batch.
2026-07-15 12:00:02 +05:30
Mihir Kandoi
839fd5e3b6 fix: name every conflicting voucher in the reserved batch error 2026-07-15 12:00:02 +05:30
Mihir Kandoi
56bbca0203 fix: allow delivery when a batch is reserved across multiple sales orders (backport #57169)
validate_reserved_batches compared the voucher's own qty against the
remaining batch qty, so delivering one order's reserved unit threw
Reserved Batch Conflict whenever the remainder exactly matched another
order's reservation. Compare the remaining batch qty against the
aggregated outstanding reserved qty (qty - delivered_qty) of other
vouchers instead, excluding reservations the voucher itself delivers.
2026-07-15 12:00:02 +05:30
Mihir Kandoi
478719b828 Merge pull request #57173 from frappe/mergify/bp/version-16-hotfix/pr-57137
feat: add on hold status to project (backport #57137)
2026-07-15 11:29:14 +05:30
Poovitha Palanivelu
57a49ce168 feat: add on hold status to project
(cherry picked from commit 672fadaa78)
2026-07-15 05:47:16 +00:00
Mihir Kandoi
6d47c51c5b Merge pull request #57166 from frappe/mergify/bp/version-16-hotfix/pr-57163
fix: hide job card field in purchase order item (backport #57163)
2026-07-15 11:03:06 +05:30
Mihir Kandoi
6ab1668b37 Merge pull request #57168 from frappe/mergify/bp/version-16-hotfix/pr-57164
fix: set correct currency in supplier quotation net rate field (backport #57164)
2026-07-15 10:58:18 +05:30
Mihir Kandoi
b256c76c3b chore: resolve conflicts 2026-07-15 10:48:51 +05:30
Mihir Kandoi
e24ef847dc fix: set correct currency in supplier quotation net rate field
(cherry picked from commit 27672851cd)
2026-07-15 05:17:11 +00:00
Mihir Kandoi
20f6ac81b9 fix: hide job card field in purchase order item
(cherry picked from commit f44bcae47d)

# Conflicts:
#	erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
2026-07-15 05:13:39 +00:00
Sudharsanan Ashok
02549ef8c4 fix(stock): correct stock ageing value for moving average and lifo items (#56693)
* fix(stock): recompute moving average item slots

* test(stock): add test to validate the stock value of moving average items

* fix(stock): support lifo valuation in stock ageing report

lifo items were aged as fifo (oldest consumed first), so the report kept the
newest lots on hand and reported the wrong stock value and average age. prefetch
each item's valuation method (it can't be resolved mid-stream without breaking the
unbuffered cursor) and consume from the tail for lifo items. also reuse that shared
lookup in the moving average revaluation pass. scoped to plain items; batch, serial
and same-voucher repack legs stay on fifo.

* test(stock): add test for lifo consumption in stock ageing report

(cherry picked from commit 9cb6610b9e)

# Conflicts:
#	erpnext/stock/report/stock_ageing/stock_ageing.py
2026-07-10 15:40:37 +05:30
Abdeali Chharchhoda
3f87836536 fix: enhance growth view filtering by validating period keys
(cherry picked from commit aad287d09e)
2026-07-08 10:44:41 +00:00
Abdeali Chharchhoda
da3844c4df fix: update formatting of growth view for FS report
(cherry picked from commit 4c7499600c)
2026-07-08 10:44:40 +00:00
Abdeali Chharchhoda
1f5281d3b8 refactor: simple utility for growth value computation for custom FS report
(cherry picked from commit 698876672d)
2026-07-08 10:44:40 +00:00
Abdeali Chharchhoda
04e7457cea refactor(financial-report): fix row transformation for growth calculations
(cherry picked from commit c179460c98)
2026-07-08 10:44:39 +00:00
248 changed files with 204279 additions and 99311 deletions

View File

@@ -19,13 +19,22 @@ import {
import { cn } from "@/lib/utils"
import _ from "@/lib/translate"
import { selectedBankAccountAtom } from "./bankRecAtoms"
import { useFrappeGetDocList } from "frappe-react-sdk"
import ErrorBanner from "@/components/ui/error-banner"
const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = window.frappe?.boot?.docs?.filter((doc: Record<string, any>) => doc.doctype === ":Company").map((company: Record<string, any>) => company.name) || []
const { data: companies, error } = useFrappeGetDocList("Company", {
limit: 0,
fields: ["name"],
}, 'company_list', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
})
const options = companies?.map((company: { name: string }) => company.name) || []
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
@@ -42,6 +51,10 @@ const CompanySelector = ({ onChange }: { onChange?: (company: string) => void })
}
}
if (error) {
return <ErrorBanner error={error} />
}
return (<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button

View File

@@ -1,7 +1,9 @@
import { useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '')
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '', undefined, {
getOnInit: true,
})
export const useCurrentCompany = () => {
const selectedCompany = useAtomValue(selectedCompanyAtom)

View File

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

View File

@@ -17,7 +17,7 @@ class ERPNextAddress(Address):
def link_address(self):
"""Link address based on owner"""
if self.is_your_company_address:
if self.get("is_your_company_address"):
return
return super().link_address()
@@ -28,7 +28,9 @@ class ERPNextAddress(Address):
self.is_your_company_address = 1
def validate_reference(self):
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
if self.get("is_your_company_address") and not [
row for row in self.links if row.link_doctype == "Company"
]:
frappe.throw(
_(
"Address needs to be linked to a Company. Please add a row for Company in the Links table."

View File

@@ -120,6 +120,7 @@ class Account(NestedSet):
self.validate_account_currency()
self.validate_root_company_and_sync_account_to_children()
self.validate_receivable_payable_account_type()
self.validate_stock_account_type_change()
def validate_parent_child_account_type(self):
if self.parent_account:
@@ -208,6 +209,36 @@ class Account(NestedSet):
frappe.msgprint(msg)
self.add_comment("Comment", msg)
def validate_stock_account_type_change(self):
doc_before_save = self.get_doc_before_save()
if not (doc_before_save and doc_before_save.account_type == "Stock"):
return
if self.account_type == "Stock":
return
if self.stock_ledger_entry_exists():
frappe.throw(
_(
"The account type of {0} cannot be changed from {1} because stock ledger entries exist against it."
).format(frappe.bold(self.name), frappe.bold(_("Stock")))
)
def stock_ledger_entry_exists(self):
from erpnext.stock import get_warehouse_account_map
warehouse_account = get_warehouse_account_map(self.company)
warehouses = [wh for wh, details in warehouse_account.items() if details.account == self.name]
if not warehouses:
return False
return bool(
frappe.db.count(
"Stock Ledger Entry",
filters={"warehouse": ("in", warehouses), "is_cancelled": 0},
)
)
def validate_root_details(self):
doc_before_save = self.get_doc_before_save()

View File

@@ -24,7 +24,8 @@
"account_number": "11530"
},
"account_number": "115",
"is_group": 1
"is_group": 1,
"account_type": "Bank"
},
"Trade Receivables": {
"Trade Debtors": {
@@ -529,6 +530,13 @@
"account_number": "630",
"is_group": 1
},
"Accrued Manufacturing Expenses": {
"Accrued Expenses - Manufacturing": {
"account_number": "63510"
},
"account_number": "635",
"is_group": 1
},
"account_number": "63",
"is_group": 1
},
@@ -814,4 +822,4 @@
"root_type": "Expense"
}
}
}
}

View File

@@ -307,6 +307,31 @@ class TestAccount(ERPNextTestSuite):
acc.account_currency = "USD"
self.assertRaises(frappe.ValidationError, acc.save)
def test_stock_account_type_change_with_ledger_entries(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
company = "_Test Company with perpetual inventory"
warehouse = "Stores - TCP1"
stock_account = get_warehouse_account(frappe.get_doc("Warehouse", warehouse))
make_stock_entry(
item_code="_Test Item",
target=warehouse,
company=company,
qty=5,
basic_rate=100,
)
account = frappe.get_doc("Account", stock_account)
self.assertEqual(account.account_type, "Stock")
account.account_type = ""
self.assertRaises(frappe.ValidationError, account.save)
account.reload()
account.account_name = f"{account.account_name} Updated"
account.save() # non-type change stays allowed
def test_account_balance(self):
from erpnext.accounts.utils import get_balance_on

View File

@@ -21,6 +21,8 @@
"enable_common_party_accounting",
"allow_multi_currency_invoices_against_single_party_account",
"confirm_before_resetting_posting_date",
"stock_expense_section",
"book_stock_expense_gl_entries",
"analytics_section",
"enable_discounts_and_margin",
"enable_accounting_dimensions",
@@ -75,6 +77,8 @@
"over_billing_allowance",
"credit_controller",
"role_allowed_to_over_bill",
"enable_overdue_billing_threshold",
"role_allowed_to_bypass_overdue_billing",
"column_break_11",
"assets_tab",
"asset_settings_section",
@@ -271,6 +275,21 @@
"label": "Role Allowed to over bill ",
"options": "Role"
},
{
"default": "0",
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Restrict Customer Over Billing"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
"description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.",
"fieldname": "role_allowed_to_bypass_overdue_billing",
"fieldtype": "Link",
"label": "Role Allowed to Bypass Over Billing Restriction",
"options": "Role"
},
{
"fieldname": "period_closing_settings_section",
"fieldtype": "Section Break"
@@ -749,6 +768,18 @@
"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"
},
{
"fieldname": "stock_expense_section",
"fieldtype": "Section Break",
"label": "Stock Expense Accounting"
},
{
"default": "0",
"description": "Books Purchase Expense and Expenses Added To Stock account pairs against stock value. On enabling this, the accounts become mandatory in Company or Item Defaults for Purchase Receipt, Purchase Invoice, Stock Entry, Stock Reconciliation and Landed Cost Voucher",
"fieldname": "book_stock_expense_gl_entries",
"fieldtype": "Check",
"label": "Book Stock Expense GL Entries"
}
],
"grid_page_length": 50,
@@ -757,7 +788,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-24 12:59:41.868865",
"modified": "2026-07-27 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -62,6 +62,7 @@ class AccountsSettings(Document):
book_asset_depreciation_entry_automatically: DF.Check
book_deferred_entries_based_on: DF.Literal["Days", "Months"]
book_deferred_entries_via_journal_entry: DF.Check
book_stock_expense_gl_entries: DF.Check
book_tax_discount_loss: DF.Check
calculate_depr_using_total_days: DF.Check
check_supplier_invoice_uniqueness: DF.Check
@@ -77,6 +78,7 @@ class AccountsSettings(Document):
enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check
enable_loyalty_point_program: DF.Check
enable_overdue_billing_threshold: DF.Check
enable_party_matching: DF.Check
enable_subscription: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
@@ -95,6 +97,7 @@ class AccountsSettings(Document):
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
repost_allowed_types: DF.Table[RepostAllowedTypes]
role_allowed_to_bypass_overdue_billing: DF.Link | None
role_allowed_to_over_bill: DF.Link | None
role_to_notify_on_depreciation_failure: DF.Link | None
role_to_override_stop_action: DF.Link | None
@@ -150,6 +153,10 @@ class AccountsSettings(Document):
toggle_subscription_sections(not self.enable_subscription)
clear_cache = True
if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold:
toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold)
clear_cache = True
if clear_cache:
frappe.clear_cache()
@@ -241,6 +248,10 @@ def toggle_subscription_sections(hide):
create_property_setter_for_hiding_field(doctype, "subscription_section", hide)
def toggle_overdue_billing_threshold_field(hide):
create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide)
def create_property_setter_for_hiding_field(doctype, field_name, hide):
make_property_setter(
doctype,

View File

@@ -143,6 +143,30 @@ def preprocess_mt940_content(content: str) -> str:
return processed_content
MT940_CUSTOMER_REFERENCE_MAX_LEN = 16
def get_transaction_reference(txn_data: dict) -> str:
"""Extract the per-transaction reference from an MT940 :61: tag.
The mt940 library exposes ``transaction_reference`` from the :20: tag, which is the
statement-level reference and identical for every transaction in a statement. The
real per-transaction reference is ``customer_reference`` (with any overflow captured
into ``extra_details`` when a bank emits a single-line :61: longer than 16 chars).
"""
customer_reference = (txn_data.get("customer_reference") or "").strip()
if len(customer_reference) == MT940_CUSTOMER_REFERENCE_MAX_LEN:
customer_reference += (txn_data.get("extra_details") or "").strip()
if customer_reference and customer_reference.upper() != "NONREF":
return customer_reference
return (txn_data.get("bank_reference") or "").strip() or (
txn_data.get("transaction_reference") or ""
).strip()
@frappe.whitelist()
def convert_mt940_to_csv(data_import, mt940_file_path):
doc = frappe.get_doc("Bank Statement Import", data_import)
@@ -190,8 +214,8 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
deposit = amount_value if amount_value > 0 else ""
withdrawal = abs(amount_value) if amount_value < 0 else ""
description = txn.data.get("extra_details") or ""
reference = txn.data.get("transaction_reference") or ""
description = txn.data.get("transaction_details") or txn.data.get("extra_details") or ""
reference = get_transaction_reference(txn.data)
currency = txn.data.get("currency", "")
writer.writerow([date_str, deposit, withdrawal, description, reference, doc.bank_account, currency])

View File

@@ -1,7 +1,10 @@
# Copyright (c) 2020, Frappe Technologies and Contributors
# See license.txt
import mt940
from erpnext.accounts.doctype.bank_statement_import.bank_statement_import import (
get_transaction_reference,
is_mt940_format,
preprocess_mt940_content,
)
@@ -188,6 +191,135 @@ class TestBankStatementImport(ERPNextTestSuite):
self.assertIn(":20:STMTREF167619", result) # Reference should remain unchanged
self.assertIn("UPI/TEST USER/123456789/PaidViaTestApp", result)
def test_get_transaction_reference_uses_customer_reference(self):
"""Per-transaction reference must come from :61: customer_reference, not :20:."""
self.assertEqual(
get_transaction_reference(
{"customer_reference": "UPI-100000000001", "transaction_reference": "STMTREF12345"}
),
"UPI-100000000001",
)
def test_get_transaction_reference_rejoins_overflow(self):
"""When a bank emits a single-line :61: with >16-char reference, the regex
splits the tail into extra_details. We must rejoin them."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "NEFTINW-12345678",
"extra_details": "90",
"transaction_reference": "STMTREF12345",
}
),
"NEFTINW-1234567890",
)
def test_get_transaction_reference_falls_back_to_bank_reference_on_nonref(self):
"""NONREF is the MT940 'no customer reference' sentinel; prefer bank_reference."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "NONREF",
"bank_reference": "1234567890123456",
"transaction_reference": "STMTREF12345",
}
),
"1234567890123456",
)
def test_get_transaction_reference_falls_back_to_bank_reference_on_nonref_with_extra_details(self):
"""NONREF sentinel must trigger the bank_reference fallback even when
extra_details is populated. Without the 16-char gate, the old naive concat
would produce a junk reference like 'NONREFsome info' and bypass the check."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "NONREF",
"extra_details": "some info",
"bank_reference": "1234567890123456",
"transaction_reference": "STMTREF12345",
}
),
"1234567890123456",
)
def test_get_transaction_reference_does_not_append_extra_details_below_16_chars(self):
"""When customer_reference is below the 16-char cap, extra_details is a
genuine supplementary-info field from :61: — not overflow — and must not
be appended to the reference."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "TBMS-123456789",
"extra_details": "note field",
"transaction_reference": "STMTREF12345",
}
),
"TBMS-123456789",
)
def test_get_transaction_reference_keeps_noref_literal(self):
"""Bare 'NOREF' (without bank_reference) stays as-is; still better than the
statement-level reference which is identical across all transactions."""
self.assertEqual(
get_transaction_reference(
{
"customer_reference": "NOREF",
"bank_reference": None,
"transaction_reference": "STMTREF12345",
}
),
"NOREF",
)
def test_mt940_parse_per_transaction_reference_mapping(self):
"""End-to-end: every transaction in a statement must get its own distinct
reference from :61: customer_reference, never the statement-level :20: reference."""
mt940_content = """{1:F0112345678901X0000000000}{2:I94012345678901XN}{4:
:20:STMTREF12345
:25:1234567890
:28C:12345/1
:60F:C250716INR88123,38
:61:2509280928D5000,00NMSCUPI-100000000001
:86:UPI/TEST PAYEE ONE/111111111111/TestApp
:61:2509190919D2606,00NMSCUPI-100000000002
:86:UPI/TEST PAYEE TWO/222222222222/TestApp
:61:2509190919D900,00NMSCUPI-100000000003
:86:UPI/TEST PAYEE THREE/333333333333/TestApp
:61:2508140814D5000,00NMSCUPI-100000000004
:86:UPI/TEST PAYEE FOUR/444444444444/TestApp
:61:2508060806D2000,00NMSCUPI-100000000005
:86:UPI/TEST PAYEE FIVE/555555555555/TestApp
:61:2508030803D1066,00NMSC123456789012
:86:PCD/1234/TEST MERCHANT/01234567890123/12:00
:61:2507310731D305,62NMSCTBMS-123456789
:86:Chrg: Debit Card Annual Fee 1234 for 2025
:61:2507240724C1,00NMSCNEFTINW-1234567890
:86:NEFT TEST123456789 TEST SERVICES
:61:2507170717C100000,00NMSCNOREF
:86:BY CLG INST 123456/01-01-25/TESTBANK/TESTCITY
:62F:C250930INR100000,00
-}"""
transactions = list(mt940.parse(preprocess_mt940_content(mt940_content)))
references = [get_transaction_reference(t.data) for t in transactions]
self.assertEqual(
references,
[
"UPI-100000000001",
"UPI-100000000002",
"UPI-100000000003",
"UPI-100000000004",
"UPI-100000000005",
"123456789012",
"TBMS-123456789",
"NEFTINW-1234567890",
"NOREF",
],
)
# No transaction should carry the statement-level reference from :20:
self.assertNotIn("STMTREF12345", references)
def test_preprocess_mt940_content_whitespace_variants(self):
"""Test handling of whitespace and different line endings"""
# Test with trailing spaces

View File

@@ -218,6 +218,7 @@ def build_forest(data):
for row in data:
account_name, parent_account, account_number, parent_account_number = row[0:4]
if account_number:
account_number = cstr(account_number).strip()
account_name = f"{account_number} - {account_name}"
if parent_account_number:
parent_account_number = cstr(parent_account_number).strip()

View File

@@ -169,23 +169,10 @@ frappe.ui.form.on("Dunning", {
},
get_dunning_letter_text: function (frm) {
if (frm.doc.dunning_type) {
frappe.call({
method: "erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text",
args: {
dunning_type: frm.doc.dunning_type,
language: frm.doc.language,
doc: frm.doc,
},
callback: function (r) {
if (r.message) {
frm.set_value("body_text", r.message.body_text);
frm.set_value("closing_text", r.message.closing_text);
frm.set_value("language", r.message.language);
} else {
frm.set_value("body_text", "");
frm.set_value("closing_text", "");
}
},
frm.call("get_dunning_letter_text").then((r) => {
if (!r.exc) {
frm.refresh_fields();
}
});
}
},

View File

@@ -163,6 +163,46 @@ class Dunning(AccountsController):
"Serial and Batch Bundle",
]
@frappe.whitelist()
def get_dunning_letter_text(self):
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
if not self.dunning_type:
return
filters = {"parent": self.dunning_type, "is_default_language": 1}
if self.language:
filters.pop("is_default_language")
filters["language"] = self.language
letter_text = frappe.db.get_value(DOCTYPE, filters, FIELDS, as_dict=True)
if not letter_text:
msg = (
_("Dunning Letter for Dunning Type {0} in language '{1}' not found.").format(
frappe.bold(self.dunning_type), frappe.bold(self.language)
)
if self.language
else _("Dunning Letter for Dunning Type {0} not found.").format(
frappe.bold(self.dunning_type)
)
)
frappe.msgprint(msg, alert=True, indicator="yellow")
self.body_text = (
frappe.render_template(letter_text.body_text, self.as_dict(), restrict_globals=True)
if letter_text
else None
)
self.closing_text = (
frappe.render_template(letter_text.closing_text, self.as_dict(), restrict_globals=True)
if letter_text
else None
)
self.language = letter_text.language if letter_text else self.language
def update_linked_dunnings(doc, previous_outstanding_amount):
if (
@@ -241,35 +281,3 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
& (overdue_payment.sales_invoice == sales_invoice)
)
).run(as_dict=True)
@frappe.whitelist()
def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict:
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
if isinstance(doc, str):
doc = json.loads(doc)
if not language:
language = doc.get("language")
letter_text = None
if language:
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1
)
if not letter_text:
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1
)
if not letter_text:
return {}
return {
"body_text": frappe.render_template(letter_text.body_text, doc),
"closing_text": frappe.render_template(letter_text.closing_text, doc),
"language": letter_text.language,
}

View File

@@ -3,7 +3,10 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import comma_and
from frappe.utils.jinja import validate_template
class DunningType(Document):
@@ -30,3 +33,134 @@ class DunningType(Document):
def autoname(self):
company_abbr = frappe.get_value("Company", self.company, "abbr")
self.name = f"{self.dunning_type} - {company_abbr}"
def validate(self):
self.validate_dunning_letter_text()
self.validate_income_account()
self.validate_cost_center()
self.set_default_dunning_type()
def validate_dunning_letter_text(self):
self.validate_languages()
self.validate_is_default_language()
self.validate_dunning_letter_text_templates()
def validate_income_account(self):
if not self.income_account:
return
account = frappe.get_cached_doc("Account", self.income_account)
msg = []
if account.company != self.company:
msg.append(
_(
"{0} doesn't belong to Company {1}. Please select an Income Account that belongs to Company {1}."
).format(frappe.bold(self.income_account), frappe.bold(self.company))
)
if account.disabled:
msg.append(
_("{0} is disabled. Please select a valid Income Account.").format(
frappe.bold(self.income_account)
)
)
if account.root_type != "Income":
msg.append(
_("{0} is not an Income Account. Please select a valid Income Account.").format(
frappe.bold(self.income_account)
)
)
if account.is_group:
msg.append(
_("{0} is a group account. Please select a non-group Income Account.").format(
frappe.bold(self.income_account)
)
)
if msg:
frappe.msgprint(
msg,
title=_("Income Account Validation Error"),
as_list=True,
raise_exception=frappe.ValidationError,
)
def validate_cost_center(self):
if not self.cost_center:
return
cost_center = frappe.get_cached_doc("Cost Center", self.cost_center)
msg = []
if cost_center.company != self.company:
msg.append(
_(
"{0} doesn't belong to Company {1}. Please select a Cost Center that belongs to Company {1}."
).format(frappe.bold(self.cost_center), frappe.bold(self.company))
)
if cost_center.disabled:
msg.append(
_("{0} is disabled. Please select an enabled Cost Center.").format(
frappe.bold(self.cost_center)
)
)
if cost_center.is_group:
msg.append(
_("{0} is a group Cost Center. Please select a non-group Cost Center.").format(
frappe.bold(self.cost_center)
)
)
if msg:
frappe.msgprint(
msg,
title=_("Cost Center Validation Error"),
as_list=True,
raise_exception=frappe.ValidationError,
)
def validate_languages(self):
languages = [d.language for d in self.dunning_letter_text]
if len(languages) == len(set(languages)):
return
frappe.throw(_("Duplicate languages found on Dunning Letter Text. Keep only one of them."))
def validate_is_default_language(self):
is_default_language_list = [
d.language for d in self.dunning_letter_text if d.is_default_language == 1
]
if len(is_default_language_list) <= 1:
return
frappe.throw(
_("{0} languages are marked as default languages. Please select only one of them.").format(
comma_and(is_default_language_list, add_quotes=True)
)
)
def validate_dunning_letter_text_templates(self):
for d in self.dunning_letter_text:
if d.body_text:
validate_template(d.body_text, restrict_globals=True)
if d.closing_text:
validate_template(d.closing_text, restrict_globals=True)
def set_default_dunning_type(self):
if self.is_default != 1:
return
frappe.db.set_value(
"Dunning Type",
{"company": self.company, "is_default": 1, "name": ["!=", self.name]},
"is_default",
0,
)

View File

@@ -1,10 +1,200 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import unittest
import frappe
from erpnext.tests.utils import ERPNextTestSuite
def make_dunning_type(dunning_type, company="_Test Company", **kwargs):
doc = frappe.new_doc("Dunning Type")
doc.dunning_type = dunning_type
doc.company = company
doc.dunning_fee = kwargs.get("dunning_fee", 100)
doc.rate_of_interest = kwargs.get("rate_of_interest", 5)
doc.is_default = kwargs.get("is_default", 0)
if "income_account" in kwargs:
doc.income_account = kwargs["income_account"]
elif kwargs.get("income_account") is not False:
doc.income_account = "Sales - _TC" if company == "_Test Company" else "Sales - _TC1"
if "cost_center" in kwargs:
doc.cost_center = kwargs["cost_center"]
elif kwargs.get("cost_center") is not False:
doc.cost_center = "Main - _TC" if company == "_Test Company" else "Main - _TC1"
for row in kwargs.get("dunning_letter_text", [{"language": "en", "body_text": "Test body"}]):
doc.append("dunning_letter_text", row)
return doc
class TestDunningType(ERPNextTestSuite):
pass
def test_income_account_must_belong_to_company(self):
doc = make_dunning_type("_Test Dunning Wrong Company Account", income_account="Sales - _TC1")
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
def test_income_account_must_not_be_disabled(self):
disabled_account = frappe.get_doc(
{
"doctype": "Account",
"account_name": "_Test Disabled Income Account",
"parent_account": "Direct Income - _TC",
"company": "_Test Company",
"account_type": "Income Account",
"disabled": 1,
}
).insert()
doc = make_dunning_type("_Test Dunning Disabled Account", income_account=disabled_account.name)
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
def test_income_account_must_be_income_type(self):
doc = make_dunning_type("_Test Dunning Non Income Account", income_account="Debtors - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is not an Income Account", doc.insert)
def test_income_account_must_not_be_group(self):
doc = make_dunning_type("_Test Dunning Group Account", income_account="Income - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is a group account", doc.insert)
def test_income_account_is_optional(self):
doc = make_dunning_type("_Test Dunning No Income Account", income_account=False)
doc.insert()
self.assertFalse(doc.income_account)
def test_valid_income_account_passes(self):
doc = make_dunning_type("_Test Dunning Valid Income Account", income_account="Sales - _TC")
doc.insert()
self.assertEqual(doc.income_account, "Sales - _TC")
def test_cost_center_must_belong_to_company(self):
doc = make_dunning_type("_Test Dunning Wrong Company CC", cost_center="Main - _TC1")
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
def test_cost_center_must_not_be_disabled(self):
disabled_cc = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Disabled Cost Center",
"parent_cost_center": "_Test Company - _TC",
"company": "_Test Company",
"disabled": 1,
}
).insert()
doc = make_dunning_type("_Test Dunning Disabled CC", cost_center=disabled_cc.name)
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
def test_cost_center_must_not_be_group(self):
doc = make_dunning_type("_Test Dunning Group CC", cost_center="_Test Company - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is a group Cost Center", doc.insert)
def test_cost_center_is_optional(self):
doc = make_dunning_type("_Test Dunning No CC", cost_center=False)
doc.insert()
self.assertFalse(doc.cost_center)
def test_valid_cost_center_passes(self):
doc = make_dunning_type("_Test Dunning Valid CC", cost_center="Main - _TC")
doc.insert()
self.assertEqual(doc.cost_center, "Main - _TC")
def test_duplicate_languages_not_allowed(self):
doc = make_dunning_type(
"_Test Dunning Duplicate Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one"},
{"language": "en", "body_text": "Body two"},
],
)
self.assertRaisesRegex(frappe.ValidationError, "Duplicate languages found", doc.insert)
def test_unique_languages_allowed(self):
doc = make_dunning_type(
"_Test Dunning Unique Languages",
dunning_letter_text=[
{"language": "en", "body_text": "Body one"},
{"language": "de", "body_text": "Body two"},
],
)
doc.insert()
self.assertEqual(len(doc.dunning_letter_text), 2)
def test_only_one_default_language_allowed(self):
doc = make_dunning_type(
"_Test Dunning Multiple Default Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one", "is_default_language": 1},
{"language": "de", "body_text": "Body two", "is_default_language": 1},
],
)
self.assertRaisesRegex(
frappe.ValidationError, "languages are marked as default languages", doc.insert
)
def test_single_default_language_allowed(self):
doc = make_dunning_type(
"_Test Dunning Single Default Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one", "is_default_language": 1},
{"language": "de", "body_text": "Body two", "is_default_language": 0},
],
)
doc.insert()
self.assertEqual(doc.dunning_letter_text[0].is_default_language, 1)
def test_invalid_jinja_template_in_body_text_raises(self):
doc = make_dunning_type(
"_Test Dunning Invalid Body Template",
dunning_letter_text=[{"language": "en", "body_text": "{{ unclosed"}],
)
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
def test_invalid_jinja_template_in_closing_text_raises(self):
doc = make_dunning_type(
"_Test Dunning Invalid Closing Template",
dunning_letter_text=[
{"language": "en", "body_text": "Valid body", "closing_text": "{{ unclosed"}
],
)
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
def test_valid_jinja_template_passes(self):
doc = make_dunning_type(
"_Test Dunning Valid Template",
dunning_letter_text=[
{
"language": "en",
"body_text": "Outstanding amount is {{ outstanding_amount }}",
"closing_text": "Regards, {{ company }}",
}
],
)
doc.insert()
self.assertTrue(doc.name)
def test_set_default_dunning_type_unsets_previous_default(self):
first = make_dunning_type("_Test Dunning Default One", is_default=1)
first.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 1)
second = make_dunning_type("_Test Dunning Default Two", is_default=1)
second.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 0)
self.assertEqual(frappe.db.get_value("Dunning Type", second.name, "is_default"), 1)
def test_set_default_dunning_type_scoped_per_company(self):
company_1 = make_dunning_type("_Test Dunning Default Co1", is_default=1)
company_1.insert()
company_2 = make_dunning_type(
"_Test Dunning Default Co2",
company="_Test Company 1",
is_default=1,
)
company_2.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", company_1.name, "is_default"), 1)
self.assertEqual(frappe.db.get_value("Dunning Type", company_2.name, "is_default"), 1)

View File

@@ -616,15 +616,27 @@ class ExchangeRateRevaluation(Document):
if journals:
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.submit()
frappe.msgprint(
_("Revaluation journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
if drafts := frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": ["in", journals]},
pluck="name",
as_list=1,
):
part = "journals are" if len(drafts) > 1 else "journal is"
doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts])
frappe.throw(
msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links),
)
else:
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.save()
frappe.msgprint(
_("A draft reverse journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
)
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):

View File

@@ -9,11 +9,10 @@ from frappe.utils import add_days, flt, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
class TestExchangeRateRevaluation(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
self.item = "_Test Item"
@@ -23,14 +22,6 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
self.set_system_and_company_settings()
def set_system_and_company_settings(self):
# set number and currency precision
system_settings = frappe.get_doc("System Settings")
system_settings.float_precision = 2
system_settings.currency_precision = 2
system_settings.language = "en"
system_settings.time_zone = "Asia/Kolkata"
system_settings.save()
# Using Exchange Gain/Loss account for unrealized as well.
company_doc = frappe.get_doc("Company", self.company)
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
@@ -300,3 +291,97 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_05_revaluation_journal_reversal(self):
"""
Test reversing of revaluation journals
"""
si = create_sales_invoice(
item=self.item,
company=self.company,
customer="_Test Customer 1",
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.posting_date = today()
err.fetch_and_calculate_accounts_data()
self.assertEqual(len(err.accounts), 1)
err.save().submit()
gain_loss_account = err.get_for_unrealized_gain_loss_account()
usd_account = err.accounts[0].account
old_balance = err.accounts[0].balance_in_base_currency
new_balance = err.accounts[0].new_balance_in_base_currency
total_gain_loss = err.total_gain_loss
# Create JV for ERR
ret = err.check_journal_and_reversal()
self.assertFalse(ret.get("journals_posted"))
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
je = je.submit()
je.reload()
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
self.assertEqual(len(je.accounts), 3)
# A gain is credited to the gain/loss account, a loss is debited. The current
# exchange rate (from master data) may sit either side of the booked rate, so
# derive the column from the sign instead of assuming a gain.
gain_loss_debit = abs(total_gain_loss) if total_gain_loss < 0 else 0.0
gain_loss_credit = total_gain_loss if total_gain_loss > 0 else 0.0
expected = [
(usd_account, new_balance, 0.0, 100.0, 0.0),
(usd_account, 0.0, old_balance, 0.0, 100.0),
(gain_loss_account, gain_loss_debit, gain_loss_credit, gain_loss_debit, gain_loss_credit),
]
actual = []
for acc in je.accounts:
actual.append(
(
acc.account,
acc.debit,
acc.credit,
acc.debit_in_account_currency,
acc.credit_in_account_currency,
)
)
self.assertEqual(expected, actual)
# Assert reversals are not posted
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertFalse(ret.get("reversals_posted"))
err.make_reverse_journal()
# submit
draft = frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"},
pluck="name",
as_list=1,
)
self.assertIsNotNone(draft)
frappe.get_doc("Journal Entry", draft[0]).submit()
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertTrue(ret.get("reversals_posted"))
reverse_jv = frappe.db.get_all(
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
)
self.assertIsNotNone(reverse_jv)

View File

@@ -1853,28 +1853,51 @@ class GrowthViewTransformer:
self.formatted_rows = context.raw_data.get("formatted_data", [])
self.period_list = context.period_list
def transform(self) -> None:
def transform(self):
for row_data in self.formatted_rows:
if row_data.get("is_blank_line"):
continue
transformed_values = {}
for i in range(len(self.period_list)):
current_period = self.period_list[i]["key"]
if row_data.get("segment_values"):
self._transform_segmented_row(row_data)
else:
self._transform_single_row(row_data)
current_value = row_data[current_period]
previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0
def _compute_growth_values(self, source: dict) -> dict:
transformed = {}
if i == 0:
transformed_values[current_period] = current_value
else:
growth_percent = self._calculate_growth(previous_value, current_value)
transformed_values[current_period] = growth_percent
for i, period in enumerate(self.period_list):
current_period = period["key"]
current_value = source.get(current_period)
row_data.update(transformed_values)
if current_value in (None, ""):
continue
if i == 0:
transformed[current_period] = current_value
else:
previous_period = self.period_list[i - 1]["key"]
previous_value = source.get(previous_period) or 0
transformed[current_period] = self._calculate_growth(previous_value, current_value)
return transformed
def _transform_single_row(self, row_data: dict):
row_data.update(self._compute_growth_values(row_data))
def _transform_segmented_row(self, row_data: dict):
for seg_id, seg_data in row_data.get("segment_values", {}).items():
if seg_data.get("is_blank_line"):
continue
transformed = self._compute_growth_values(seg_data)
seg_data.update(transformed)
for period_key, value in transformed.items():
row_data[f"{seg_id}_{period_key}"] = value
def _calculate_growth(self, previous_value: float, current_value: float) -> float | None:
if current_value is None:
if current_value in (None, ""):
return None
if previous_value == 0 and current_value > 0:

View File

@@ -567,6 +567,7 @@ $.extend(erpnext.journal_entry, {
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname))
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -3326,13 +3326,11 @@ def set_paid_amount_and_received_amount(
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
if bank and company_currency != bank.account_currency:
# doc currency can be different from bank currency
posting_date = doc.get("posting_date") or doc.get("transaction_date")
conversion_rate = get_exchange_rate(
bank.account_currency, party_account_currency, posting_date
)
conversion_rate = get_exchange_rate(bank.account_currency, party_account_currency)
received_amount = paid_amount / conversion_rate
else:
received_amount = paid_amount * doc.get("conversion_rate", 1)
conversion_rate = get_exchange_rate(doc.get("currency", company_currency), company_currency)
received_amount = paid_amount * conversion_rate
# if payment type is pay, then paid amount and received amount are swapped
if payment_type == "Pay":

View File

@@ -2405,6 +2405,86 @@ class TestPaymentReconciliation(ERPNextTestSuite):
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
pr.reconcile()
def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self):
transaction_date = nowdate()
self.supplier = "_Test Supplier USD"
amount = 100
department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name")
# Pay USD 100 at an exchange rate of 90.
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
pe.payment_type = "Pay"
pe.party_type = "Supplier"
pe.party = self.supplier
pe.paid_from = self.cash
pe.paid_from_account_currency = "INR"
pe.target_exchange_rate = 90
pe.paid_amount = 90 * amount
pe.received_amount = amount
pe.paid_to = self.creditors_usd
pe.paid_to_account_currency = "USD"
pe.department = department
pe = pe.save().submit()
# Receive USD 100 from the supplier at an exchange rate of 100.
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
reverse_pe.payment_type = "Receive"
reverse_pe.party_type = "Supplier"
reverse_pe.party = self.supplier
reverse_pe.paid_from = self.creditors_usd
reverse_pe.paid_from_account_currency = "USD"
reverse_pe.source_exchange_rate = 100
reverse_pe.paid_amount = amount
reverse_pe.received_amount = 100 * amount
reverse_pe.paid_to = self.cash
reverse_pe.paid_to_account_currency = "INR"
reverse_pe.department = department
reverse_pe = reverse_pe.save().submit()
pr = self.create_payment_reconciliation(party_is_customer=False)
pr.party = self.supplier
pr.receivable_payable_account = self.creditors_usd
pr.get_unreconciled_entries()
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
for row in pr.allocation:
row.department = department
self.assertEqual(flt(pr.allocation[0].difference_amount), 1000)
pr.reconcile()
gain_loss_journal = frappe.db.get_value(
"Journal Entry Account",
{
"reference_type": reverse_pe.doctype,
"reference_name": reverse_pe.name,
"party": self.supplier,
"docstatus": 1,
},
"parent",
)
party_row = frappe.db.get_value(
"Journal Entry Account",
{"parent": gain_loss_journal, "party": self.supplier},
["debit", "credit"],
as_dict=True,
)
self.assertEqual(flt(party_row.debit), 1000)
self.assertEqual(flt(party_row.credit), 0)
party_gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]],
"account": self.creditors_usd,
"party": self.supplier,
"is_cancelled": 0,
},
fields=["debit", "credit"],
)
self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0)
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer_usd

View File

@@ -14,7 +14,8 @@
"section_break_mjlv",
"due_date",
"column_break_qghl",
"amount"
"amount",
"currency"
],
"fields": [
{
@@ -55,8 +56,18 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount",
"options": "currency",
"precision": "2"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"hidden": 1,
"label": "Currency",
"options": "Currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_lnjp",
"fieldtype": "Column Break"
@@ -74,7 +85,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-19 02:21:36.455830",
"modified": "2026-07-11 00:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reference",

View File

@@ -37,6 +37,8 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
frm.set_intro(__("Failure: {0}", [frm.doc.failed_reason]), "red");
}
let sending_email = false;
if (
frm.doc.payment_request_type == "Inward" &&
frm.doc.payment_channel !== "Phone" &&
@@ -45,16 +47,16 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
frm.doc.docstatus == 1
) {
frm.add_custom_button(__("Resend Payment Email"), function () {
frappe.call({
method: "erpnext.accounts.doctype.payment_request.payment_request.resend_payment_email",
args: { docname: frm.doc.name },
freeze: true,
freeze_message: __("Sending"),
callback: function (r) {
if (!r.exc) {
frappe.msgprint(__("Message Sent"));
}
},
if (sending_email) {
frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
return;
}
sending_email = true;
frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
frm.call("resend_payment_email").then((r) => {
const msg = !r.exc ? __("Email Sent") : __("Email couldn't be sent.");
frappe.show_alert({ message: msg, indicator: !r.exc ? "green" : "red" });
sending_email = false;
});
});
}

View File

@@ -423,6 +423,18 @@ class PaymentRequest(Document):
return payment_entry
@frappe.whitelist(methods=["POST"])
def resend_payment_email(self):
if not (
self.docstatus == 1
and self.payment_request_type == "Inward"
and self.payment_channel != "Phone"
and self.status not in ["Initiated", "Paid"]
):
frappe.throw(_("Payment Link couldn't be sent."))
self.send_email()
def send_email(self):
"""send email with payment link"""
email_args = {
@@ -440,11 +452,14 @@ class PaymentRequest(Document):
)
],
}
job_id = f"send_payment_email::{self.name}"
enqueue(
method=frappe.sendmail,
queue="short",
timeout=300,
is_async=True,
job_id=job_id,
deduplicate=True,
enqueue_after_commit=True,
**email_args,
)
@@ -784,6 +799,7 @@ def set_payment_references(payment_schedules):
"description": row.get("description"),
"due_date": row.get("due_date"),
"amount": row.get("payment_amount"),
"currency": row.get("currency"),
}
)
@@ -950,11 +966,6 @@ def get_print_format_list(ref_doctype):
return {"print_format": print_format_list}
@frappe.whitelist()
def resend_payment_email(docname):
return frappe.get_doc("Payment Request", docname).send_email()
@frappe.whitelist()
def make_payment_entry(docname):
doc = frappe.get_doc("Payment Request", docname)

View File

@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.account_closing_balance.account_closing_balance im
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
@@ -46,6 +47,14 @@ class PeriodClosingVoucher(AccountsController):
self.block_if_future_closing_voucher_exists()
self.check_closing_account_type()
self.check_closing_account_currency()
self.validate_accounts_not_frozen()
def validate_accounts_not_frozen(self, for_cancellation=False):
posting_date = self.period_end_date
if for_cancellation and is_immutable_ledger_enabled():
posting_date = getdate()
check_freezing_date(posting_date, self.company)
def validate_start_and_end_date(self):
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
@@ -147,6 +156,7 @@ class PeriodClosingVoucher(AccountsController):
"Process Period Closing Voucher",
)
self.block_if_future_closing_voucher_exists()
self.validate_accounts_not_frozen(for_cancellation=True)
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
self.cancel_process_pcv_docs()

View File

@@ -217,7 +217,8 @@ class POSClosingEntry(StatusUpdater):
self.update_sales_invoices_closing_entry()
def before_cancel(self):
self.check_pce_is_cancellable()
if self.status != "Failed":
self.check_pce_is_cancellable()
def on_cancel(self):
unconsolidate_pos_invoices(closing_entry=self)

View File

@@ -234,15 +234,18 @@ def get_item_groups(pos_profile):
for data in pos_profile.get("item_groups"):
item_groups.extend(
[
"%s" % frappe.db.escape(d.name)
d.name
for d in get_child_nodes("Item Group", data.item_group)
if not permitted_item_groups or d.name in permitted_item_groups
]
)
if not item_groups and permitted_item_groups:
item_groups = ["%s" % frappe.db.escape(d) for d in permitted_item_groups]
item_groups = list(permitted_item_groups)
# Return raw Item Group names; the callers parameterize them via the query builder
# (item_group.isin(...)) / frappe.get_all, which escapes them once. Pre-escaping here would
# double-escape (item_group IN ('''X''')) and match nothing.
return list(set(item_groups))

View File

@@ -99,9 +99,9 @@ class ProcessStatementOfAccounts(Document):
if not self.pdf_name:
self.pdf_name = "{{ customer.customer_name }}"
validate_template(self.subject)
validate_template(self.body)
validate_template(self.pdf_name)
validate_template(self.subject, restrict_globals=True)
validate_template(self.body, restrict_globals=True)
validate_template(self.pdf_name, restrict_globals=True)
if not self.customers:
frappe.throw(_("Customers not selected."))
@@ -527,15 +527,15 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
if report:
for customer, report_pdf in report.items():
context = get_context(customer, doc)
filename = frappe.render_template(doc.pdf_name, context)
filename = frappe.render_template(doc.pdf_name, context, restrict_globals=True)
attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}]
recipients, cc = get_recipients_and_cc(customer, doc)
if not recipients:
continue
subject = frappe.render_template(doc.subject, context)
message = frappe.render_template(doc.body, context)
subject = frappe.render_template(doc.subject, context, restrict_globals=True)
message = frappe.render_template(doc.body, context, restrict_globals=True)
if doc.sender:
sender_email = frappe.db.get_value("Email Account", doc.sender, "email_id")

View File

@@ -1380,7 +1380,20 @@ class PurchaseInvoice(BuyingController):
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
@@ -1405,7 +1418,20 @@ class PurchaseInvoice(BuyingController):
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
stock_adjustment_amt = warehouse_debit_amount - stock_amount

View File

@@ -1490,6 +1490,96 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account)
def test_stock_adjustment_account_fallbacks_when_default_expense_account_unset(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice
class StockAdjustmentInvoice:
company = "_Test Company"
conversion_rate = 1
update_stock = 1
is_internal_supplier = 0
return_against = None
project = None
def __init__(self, is_return, defaults):
self.is_return = is_return
self.defaults = defaults
def get(self, fieldname):
return None
def get_company_default(self, fieldname, ignore_validation=False):
return self.defaults.get(fieldname)
def get_gl_dict(self, args, *unused_args, **unused_kwargs):
return frappe._dict(args)
def make_invoice(is_return, defaults):
return StockAdjustmentInvoice(is_return, defaults)
def make_item(is_fixed_asset=0, expense_account="Item Expense - _TC"):
return frappe._dict(
{
"name": "row-1",
"warehouse": "Stores - _TC",
"valuation_rate": 10,
"qty": 10,
"conversion_factor": 1,
"base_net_amount": 100,
"item_tax_amount": 0,
"landed_cost_voucher_amount": 0,
"sales_incoming_rate": 0,
"is_fixed_asset": is_fixed_asset,
"expense_account": expense_account,
"cost_center": "Main - _TC",
"project": None,
"precision": lambda fieldname: 2,
}
)
defaults = {
"default_expense_account": None,
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
"asset_received_but_not_billed": "Asset Received But Not Billed - _TC",
}
test_cases = (
(
"company default expense",
0,
make_item(),
{**defaults, "default_expense_account": "Default Expense - _TC"},
"Default Expense - _TC",
),
("stock rbnb", 0, make_item(), defaults, "Stock Received But Not Billed - _TC"),
(
"asset rbnb",
0,
make_item(is_fixed_asset=1),
defaults,
"Asset Received But Not Billed - _TC",
),
("return item expense", 1, make_item(), defaults, "Item Expense - _TC"),
(
"return without item expense",
1,
make_item(expense_account=None),
defaults,
"Stock Received But Not Billed - _TC",
),
)
for label, is_return, item, company_defaults, expected_account in test_cases:
with self.subTest(label=label):
invoice = make_invoice(is_return, company_defaults)
gl_entries = []
PurchaseInvoice.make_stock_adjustment_entry(
invoice, gl_entries, item, {(item.name, item.warehouse): 90}, "INR"
)
self.assertEqual(gl_entries[0].account, expected_account)
self.assertEqual(gl_entries[0].debit, 10)
self.assertEqual(gl_entries[0].debit_in_transaction_currency, 10)
@ERPNextTestSuite.change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry

View File

@@ -22,27 +22,50 @@ frappe.ui.form.on("Repost Accounting Ledger", {
},
refresh: function (frm) {
frm.add_custom_button(__("Show Preview"), () => {
frm.call({
method: "generate_preview",
doc: frm.doc,
freeze: true,
freeze_message: __("Generating Preview"),
callback: function (r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: { orientation: "landscape" },
columns: [],
data: [],
};
frappe.render_grid(opts);
}
},
// the server refuses only while the job is alive, so a dead one can be restarted here
if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
frm.add_custom_button(__("Start Reposting"), () => {
frm.events.start_repost(frm);
});
}
if (frm.doc.docstatus != 2) {
frm.add_custom_button(__("Show Preview"), () => {
frm.events.generate_preview(frm);
});
}
},
generate_preview: function (frm) {
frm.call({
method: "generate_preview",
doc: frm.doc,
freeze: true,
freeze_message: __("Generating Preview"),
callback: function (r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: { orientation: "landscape" },
columns: [],
data: [],
};
frappe.render_grid(opts);
}
},
});
},
start_repost: function (frm) {
frm.call({
method: "start_repost",
doc: frm.doc,
callback: function (r) {
frm.reload_doc();
},
});
},
});

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2023-07-04 13:07:32.923675",
"default_view": "List",
"doctype": "DocType",
@@ -7,16 +8,24 @@
"engine": "InnoDB",
"field_order": [
"company",
"column_break_vpup",
"delete_cancelled_entries",
"column_break_vpup",
"status",
"section_break_metl",
"vouchers",
"amended_from"
"error_section",
"error_log",
"miscellaneous_section",
"amended_from",
"column_break_hrah",
"scheduled_job"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company",
"options": "Company"
},
@@ -48,12 +57,54 @@
"fieldname": "delete_cancelled_entries",
"fieldtype": "Check",
"label": "Delete Cancelled Ledger Entries"
},
{
"fieldname": "error_section",
"fieldtype": "Section Break",
"label": "Error"
},
{
"fieldname": "error_log",
"fieldtype": "Code",
"label": "Error Log",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "miscellaneous_section",
"fieldtype": "Section Break",
"label": "Miscellaneous"
},
{
"fieldname": "column_break_hrah",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.docstatus >= 1;",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "\nQueued\nIn Progress\nPartially Reposted\nCompleted\nFailed\nCancelled",
"read_only": 1
},
{
"fieldname": "scheduled_job",
"fieldtype": "Link",
"hidden": 1,
"label": "Scheduled Job",
"no_copy": 1,
"options": "RQ Job",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-06-03 17:30:37.012593",
"modified": "2026-07-28 00:56:50.290314",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger",
@@ -76,8 +127,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -7,9 +7,14 @@ import frappe
from frappe import _, qb
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
from frappe.model.document import Document
from frappe.utils.background_jobs import create_job_id, is_job_enqueued
from frappe.utils.data import comma_and
from frappe.utils.scheduler import is_scheduler_inactive
from erpnext.stock import get_warehouse_account_map
# a batch has to finish well within the timeout of the job reposting it
MAX_VOUCHERS_PER_REPOST = 50
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
class RepostAccountingLedger(Document):
@@ -28,6 +33,11 @@ class RepostAccountingLedger(Document):
amended_from: DF.Link | None
company: DF.Link | None
delete_cancelled_entries: DF.Check
error_log: DF.Code | None
scheduled_job: DF.Link | None
status: DF.Literal[
"", "Queued", "In Progress", "Partially Reposted", "Completed", "Failed", "Cancelled"
]
vouchers: DF.Table[RepostAccountingLedgerItems]
# end: auto-generated types
@@ -37,6 +47,11 @@ class RepostAccountingLedger(Document):
def validate(self):
self.validate_vouchers()
self.validate_repost_preconditions()
def validate_repost_preconditions(self):
"""The checks a repost queued days ago could have outlived, re-run before it touches
the ledger. Vouchers cancelled since are skipped one by one while reposting."""
self.validate_for_closed_fiscal_year()
self.validate_for_deferred_accounting()
@@ -73,8 +88,52 @@ class RepostAccountingLedger(Document):
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
def validate_vouchers(self):
if self.vouchers:
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
if not self.vouchers:
frappe.throw(_("Add atleast one voucher to repost."))
if len(self.vouchers) > MAX_VOUCHERS_PER_REPOST:
frappe.throw(
_("Cannot repost more than {0} vouchers at once. Split them into multiple documents.").format(
MAX_VOUCHERS_PER_REPOST
)
)
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
self.validate_no_duplicate_vouchers()
self.validate_vouchers_are_submitted()
def validate_no_duplicate_vouchers(self):
vouchers = [(x.voucher_type, x.voucher_no) for x in self.vouchers]
if len(vouchers) != len(set(vouchers)):
frappe.throw(_("Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."))
def validate_vouchers_are_submitted(self):
voucher_type_wise_map = {}
for d in self.vouchers:
voucher_type_wise_map.setdefault(d.voucher_type, [])
voucher_type_wise_map[d.voucher_type].append(d.voucher_no)
non_submitted_vouchers = []
for key in voucher_type_wise_map.keys():
non_submitted_vouchers.extend(
frappe.get_all(
key,
filters={"name": ["in", voucher_type_wise_map[key]], "docstatus": ["!=", 1]},
pluck="name",
)
)
if non_submitted_vouchers:
frappe.throw(
_("The following vouchers are not submitted: {0}").format(
comma_and(non_submitted_vouchers, add_quotes=True)
)
)
def on_discard(self):
self.db_set("status", "Cancelled")
def get_existing_ledger_entries(self):
vouchers = [x.voucher_no for x in self.vouchers]
@@ -139,80 +198,245 @@ class RepostAccountingLedger(Document):
return rendered_page
def on_submit(self):
if len(self.vouchers) > 5:
job_name = "repost_accounting_ledger_" + self.name
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
account_repost_doc=self.name,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
self.start_repost()
def before_cancel(self):
self._raise_error_if_reposting_in_progress()
def on_cancel(self):
self.db_set("status", "Cancelled")
def _raise_error_if_reposting_in_progress(self):
if self.scheduled_job and is_job_enqueued(_repost_job_id(self.name)):
frappe.throw(_("Reposting is still in progress in background."))
@frappe.whitelist()
def start_repost(self):
if self.docstatus != 1:
frappe.throw(_("Reposting can be started only for submitted document."))
# under a row lock, so two concurrent starts cannot both get past here
status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
if status in ("Completed", "Cancelled"):
frappe.throw(_("Reposting cannot be started when status is {0}.").format(status))
# `Queued` and `In Progress` are held back by the job, not by the status: a worker that
# died leaves the status behind and the document has to stay restartable
self._raise_error_if_reposting_in_progress()
self.check_permission("write")
# workers pick up enqueued jobs whether or not the scheduler runs, so this is a warning
if is_scheduler_inactive():
frappe.msgprint(
_("Scheduler is inactive. Reposting will only run once background jobs are processed."),
alert=True,
indicator="orange",
)
frappe.msgprint(_("Repost has started in the background"))
else:
start_repost(self.name)
self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
_enqueue_repost(self.name)
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
@frappe.whitelist()
def start_repost(account_repost_doc: str | None = None) -> None:
from erpnext.accounts.general_ledger import make_reverse_gl_entries
def _repost_job_id(repost_doc_name: str) -> str:
"""Derived from the document, so a repost can only ever have one job."""
return f"repost_accounting_ledger::{repost_doc_name}"
def _enqueue_repost(repost_doc_name: str) -> None:
"""Hand the repost to a background worker.
Tests run it in the foreground, inside their own transaction: documents edited after submit
repost themselves through `repost_accounting_entries`, and tests across apps assert on the
ledger right after doing so.
"""
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.repost",
repost_doc_name=repost_doc_name,
commit=not frappe.in_test,
queue="long",
timeout=1500,
job_id=_repost_job_id(repost_doc_name),
deduplicate=True,
enqueue_after_commit=True,
now=frappe.in_test,
)
def _lock_vouchers(vouchers) -> dict:
"""Lock every voucher up front so a concurrent repost cannot touch the same GL entries.
Returns them keyed by voucher, so reposting does not load them again. These are file locks
under the site directory: they serialise nothing across hosts that do not share it, and a
worker killed outright leaves them behind until they expire.
"""
locked_docs = {}
try:
for x in vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
doc.lock()
locked_docs[(x.voucher_type, x.voucher_no)] = doc
except Exception:
for doc in locked_docs.values():
doc.unlock()
raise
return locked_docs
def repost(repost_doc_name: str, commit: bool = True):
"""Repost every voucher of the document, one transaction at a time.
`commit` says whether this call owns the transaction. The background job does, and commits
after every voucher so progress survives a crash; a caller inside its own passes `False`.
"""
from erpnext.accounts.utils import _delete_accounting_ledger_entries, _delete_adv_pl_entries
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting
repost_doc.validate_for_deferred_accounting()
repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
locked_docs = {}
for x in repost_doc.vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
try:
repost_doc.validate_repost_preconditions()
# a retry leaves the vouchers it is done with alone: they are not locked, not loaded
# and not reposted again
pending = [x for x in repost_doc.vouchers if x.status not in HANDLED_VOUCHER_STATUSES]
locked_docs = _lock_vouchers(pending)
repost_doc.db_set("status", "In Progress", commit=commit)
for position, x in enumerate(pending, start=1):
frappe.publish_progress(
position * 100 / len(pending),
doctype=repost_doc.doctype,
docname=repost_doc.name,
description=_("Reposting {0} {1}").format(x.voucher_type, x.voucher_no),
)
save_point = "reposting"
frappe.db.savepoint(save_point=save_point)
try:
doc = locked_docs[(x.voucher_type, x.voucher_no)]
if doc.docstatus == 2:
x.db_set({"status": "Skipped", "traceback": ""})
continue
if repost_doc.delete_cancelled_entries:
frappe.db.delete(
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
)
frappe.db.delete(
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
)
frappe.db.delete(
"Advance Payment Ledger Entry",
filters={"voucher_type": doc.doctype, "voucher_no": doc.name},
)
_delete_accounting_ledger_entries(doc.doctype, doc.name)
_delete_adv_pl_entries(doc.doctype, doc.name)
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel(from_repost=True)
_repost_vouchers(doc, repost_doc.delete_cancelled_entries)
except Exception:
frappe.db.rollback(save_point=save_point)
doc.docstatus = 1
if doc.doctype == "Sales Invoice":
doc.force_set_against_income_account()
else:
doc.force_set_against_expense_account()
doc.make_gl_entries()
x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
else:
x.db_set({"status": "Reposted", "traceback": ""})
finally:
if commit:
frappe.db.commit() # nosemgrep
elif doc.doctype == "Purchase Receipt":
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel(from_repost=True)
except Exception:
if commit:
frappe.db.rollback()
doc.docstatus = 1
doc.make_gl_entries(from_repost=True)
_record_repost_failure(repost_doc, commit=commit)
raise
else:
repost_doc.db_set({"status": _derive_status(repost_doc), "error_log": ""}, notify=True)
finally:
for doc in locked_docs.values():
doc.unlock()
if commit:
frappe.db.commit() # nosemgrep
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
if not repost_doc.delete_cancelled_entries:
doc.make_gl_entries(1)
doc.make_gl_entries()
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries):
if not repost_doc.delete_cancelled_entries:
if "cancel" in inspect.getfullargspec(doc.make_gl_entries):
doc.make_gl_entries(cancel=1)
else:
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name)
doc.make_gl_entries()
def _derive_status(repost_doc) -> str:
"""Vouchers are committed one by one, so the status follows what was actually handled."""
handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
if handled == len(repost_doc.vouchers):
return "Completed"
elif handled == 0:
return "Failed"
return "Partially Reposted"
def _record_repost_failure(repost_doc, commit=False) -> None:
"""Persist the traceback of a run that could not finish, without discarding its progress."""
# the traceback with frame locals goes to the Error Log, which is permissioned separately
traceback = frappe.get_traceback()
frappe.log_error(
title=_("Unable to Repost Accounting Ledger"),
reference_doctype=repost_doc.doctype,
reference_name=repost_doc.name,
)
frappe.db.set_value(
repost_doc.doctype, repost_doc.name, {"error_log": traceback, "status": _derive_status(repost_doc)}
)
if commit:
frappe.db.commit()
def _repost_vouchers(doc, delete_cancelled_entries: bool | int | None):
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
_repost_invoices(doc, delete_cancelled_entries)
elif doc.doctype == "Purchase Receipt":
_repost_purchase_receipt(doc, delete_cancelled_entries)
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
_repost_pe_je(doc, delete_cancelled_entries)
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries)
def _repost_invoices(invoice_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
invoice_doc.docstatus = 2
invoice_doc.make_gl_entries_on_cancel(from_repost=True)
invoice_doc.docstatus = 1
if invoice_doc.doctype == "Sales Invoice":
invoice_doc.force_set_against_income_account()
else:
invoice_doc.force_set_against_expense_account()
invoice_doc.make_gl_entries()
def _repost_purchase_receipt(receipt_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
receipt_doc.docstatus = 2
receipt_doc.make_gl_entries_on_cancel(from_repost=True)
receipt_doc.docstatus = 1
receipt_doc.make_gl_entries(from_repost=True)
def _repost_pe_je(entry_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
entry_doc.make_gl_entries(cancel=1)
entry_doc.make_gl_entries()
def _repost_allowed_hook_doctypes(repost_doc, delete_cancelled_entries: bool | int | None):
from erpnext.accounts.general_ledger import make_reverse_gl_entries
if hasattr(repost_doc, "make_gl_entries") and callable(repost_doc.make_gl_entries):
if not delete_cancelled_entries:
if "cancel" in inspect.getfullargspec(repost_doc.make_gl_entries).args:
repost_doc.make_gl_entries(cancel=1)
else:
make_reverse_gl_entries(voucher_type=repost_doc.doctype, voucher_no=repost_doc.name)
repost_doc.make_gl_entries()
def get_allowed_types_from_settings(child_doc: bool = False):
@@ -243,19 +467,24 @@ def get_child_docs(doc: list) -> list:
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
docs_with_deferred_revenue = ()
docs_with_deferred_expense = ()
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if sales_docs:
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
if purchase_docs:
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(

View File

@@ -0,0 +1,16 @@
frappe.listview_settings["Repost Accounting Ledger"] = {
add_fields: ["status"],
// drafts and cancelled documents are coloured by the framework before it gets here
get_indicator: function (doc) {
if (!doc.status) return;
const status_color = {
Queued: "yellow",
"In Progress": "blue",
"Partially Reposted": "orange",
Completed: "green",
Failed: "red",
};
return [__(doc.status), status_color[doc.status] || "gray", "status,=," + doc.status];
},
};

View File

@@ -1,27 +1,42 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from contextlib import contextmanager
from unittest.mock import patch
import frappe
from frappe import qb
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, nowdate, today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
_lock_vouchers,
_record_repost_failure,
_repost_allowed_hook_doctypes,
_repost_job_id,
_repost_vouchers,
repost,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.utils import get_fiscal_year
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
SIMULATED_FAILURE = "Simulated repost failure"
class TestRepostAccountingLedger(ERPNextTestSuite):
def setUp(self):
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
update_repost_settings()
def test_01_basic_functions(self):
si = create_sales_invoice(
def make_invoice(self, **kwargs):
return create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
@@ -29,8 +44,71 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
**kwargs,
)
def make_invoice_and_payment(self):
si = self.make_invoice()
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
return si, pe
def create_repost_doc(self, vouchers, delete_cancelled_entries=False, submit=False):
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = delete_cancelled_entries
for voucher in vouchers:
ral.append("vouchers", {"voucher_type": voucher.doctype, "voucher_no": voucher.name})
ral.save()
if submit:
ral.submit()
ral.reload()
return ral
@contextmanager
def patched_repost(self, fail_for=()):
"""Yield the vouchers handed over to `_repost_vouchers`, failing the given types."""
reposted = []
def repost_voucher(doc, delete_cancelled_entries):
reposted.append(doc.name)
if doc.doctype in fail_for:
frappe.throw(SIMULATED_FAILURE)
_repost_vouchers(doc, delete_cancelled_entries)
with patch(f"{REPOST_MODULE}._repost_vouchers", new=repost_voucher):
yield reposted
def make_period_closing_voucher(self):
fy = get_fiscal_year(today(), company="_Test Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": "_Test Company",
"fiscal_year": fy[0],
"cost_center": "Main - _TC",
"closing_account_head": "Retained Earnings - _TC",
"remarks": "test",
}
)
return pcv.save().submit()
def get_gl_totals(self, voucher_no, is_cancelled=0):
gl = qb.DocType("GL Entry")
return (
qb.from_(gl)
.select(Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == voucher_no) & (gl.is_cancelled == is_cancelled))
.run()
)[0]
def test_01_basic_functions(self):
si = self.make_invoice()
preq = frappe.get_doc(
make_payment_request(
dt=si.doctype,
@@ -64,51 +142,24 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": "Debtors - _TC"})
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
gl = qb.DocType("GL Entry")
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.run()
)
# Assert incorrect ledger balance
self.assertNotEqual(res[0], (si.name, 100, 100))
self.assertNotEqual(self.get_gl_totals(si.name), (100, 100))
# Submit repost document
ral.save().submit()
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.run()
)
# Ledger should reflect correct amount post repost
self.assertEqual(res[0], (si.name, 100, 100))
self.assertEqual(self.get_gl_totals(si.name), (100, 100))
def test_02_deferred_accounting_valiations(self):
si = create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
do_not_submit=True,
)
si = self.make_invoice(do_not_submit=True)
si.items[0].enable_deferred_revenue = True
si.items[0].deferred_revenue_account = "Deferred Revenue - _TC"
si.items[0].service_start_date = nowdate()
si.items[0].service_end_date = add_days(nowdate(), 90)
si.save().submit()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_04_pcv_validation(self):
@@ -116,86 +167,29 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
gl = frappe.qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
si = create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
fy = get_fiscal_year(today(), company="_Test Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": "_Test Company",
"fiscal_year": fy[0],
"cost_center": "Main - _TC",
"closing_account_head": "Retained Earnings - _TC",
"remarks": "test",
}
)
pcv.save().submit()
si = self.make_invoice()
pcv = self.make_period_closing_voucher()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
pcv.reload()
pcv.cancel()
pcv.delete()
def test_03_deletion_flag_and_preview_function(self):
si = create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
si, pe = self.make_invoice_and_payment()
# with deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
def test_05_without_deletion_flag(self):
si = create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
debit_to="Debtors - _TC",
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
si, pe = self.make_invoice_and_payment()
# without deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = False
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
self.create_repost_doc([si, pe], submit=True)
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
@@ -246,11 +240,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
another_provisional_account,
)
repost_doc = frappe.new_doc("Repost Accounting Ledger")
repost_doc.company = "_Test Company"
repost_doc.delete_cancelled_entries = True
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
repost_doc.save().submit()
repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
expected_pr_gles_after_repost = [
@@ -271,6 +261,281 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
company.default_provisional_account = None
company.save()
def test_07_voucher_validations(self):
submitted_si = self.make_invoice()
draft_si = self.make_invoice(do_not_submit=True)
cancelled_si = self.make_invoice()
cancelled_si.cancel()
for vouchers, exception, message in (
([], frappe.ValidationError, "Add atleast one voucher"),
([submitted_si, submitted_si], frappe.ValidationError, "Duplicate vouchers found"),
([draft_si], frappe.ValidationError, f"not submitted.*{draft_si.name}"),
# cancelled vouchers don't make it past link validation
([cancelled_si], frappe.CancelledLinkError, "Cannot link cancelled document"),
):
with self.subTest(vouchers=[x.name for x in vouchers]):
self.assertRaisesRegex(exception, message, self.create_repost_doc, vouchers)
self.create_repost_doc([submitted_si])
def test_08_voucher_count_limit(self):
si, pe = self.make_invoice_and_payment()
another_si = self.make_invoice()
with patch(f"{REPOST_MODULE}.MAX_VOUCHERS_PER_REPOST", 2):
self.create_repost_doc([si, pe])
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot repost more than 2 vouchers",
self.create_repost_doc,
[si, pe, another_si],
)
def test_09_status_lifecycle(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
self.assertEqual(ral.status, "")
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Completed")
self.assertFalse(ral.error_log)
for voucher in ral.vouchers:
self.assertEqual(voucher.status, "Reposted")
self.assertFalse(voucher.traceback)
ral.cancel()
ral.reload()
self.assertEqual(ral.status, "Cancelled")
discarded = self.create_repost_doc([si])
discarded.discard()
discarded.reload()
self.assertEqual(discarded.status, "Cancelled")
def test_10_start_repost_guards(self):
si = self.make_invoice()
ral = self.create_repost_doc([si])
self.assertRaisesRegex(frappe.ValidationError, "only for submitted document", ral.start_repost)
ral.submit()
ral.reload()
self.assertRaisesRegex(
frappe.ValidationError, "cannot be started when status is Completed", ral.start_repost
)
# a document left behind by a worker that died mid-repost
ral.db_set("status", "In Progress")
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=True):
self.assertRaisesRegex(
frappe.ValidationError, "still in progress in background", ral.start_repost
)
self.assertRaisesRegex(frappe.ValidationError, "still in progress in background", ral.cancel)
# `cancel` flips docstatus in memory before running `before_cancel`
ral.reload()
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=False):
# the job is gone, so `In Progress` must not keep the document stuck
ral.start_repost()
ral.reload()
self.assertEqual(ral.status, "Completed")
def test_11_repost_job_is_tied_to_the_document(self):
si = self.make_invoice()
ral = self.create_repost_doc([si], submit=True)
ral.db_set("status", "Failed")
with patch(f"{REPOST_MODULE}.frappe.enqueue") as enqueue:
ral.start_repost()
kwargs = enqueue.call_args.kwargs
self.assertEqual(kwargs["repost_doc_name"], ral.name)
self.assertEqual(kwargs["job_id"], _repost_job_id(ral.name))
# a second start cannot queue a second job for the same document
self.assertTrue(kwargs["deduplicate"])
def test_12_voucher_failures_are_isolated_and_retried(self):
si, pe = self.make_invoice_and_payment()
pe_gl_entries = frappe.db.count("GL Entry", {"voucher_no": pe.name})
# the deletion flag drops the existing entries before reposting them
ral = self.create_repost_doc([si, pe], delete_cancelled_entries=True)
with self.patched_repost(fail_for=["Payment Entry"]):
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Partially Reposted")
si_row, pe_row = ral.vouchers
self.assertEqual((si_row.status, pe_row.status), ("Reposted", "Failed"))
self.assertFalse(si_row.traceback)
self.assertIn(SIMULATED_FAILURE, pe_row.traceback)
# the failed voucher is rolled back to its savepoint, so its entries are back
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pe.name}), pe_gl_entries)
# a retry only picks up the vouchers that are not reposted yet, and leaves the rest
# alone entirely: they are not locked or loaded either
with (
patch(f"{REPOST_MODULE}._lock_vouchers", side_effect=_lock_vouchers) as lock_vouchers,
self.patched_repost() as retried,
):
ral.start_repost()
self.assertEqual(retried, [pe.name])
self.assertEqual([x.voucher_no for x in lock_vouchers.call_args.args[0]], [pe.name])
ral.reload()
self.assertEqual(ral.status, "Completed")
for voucher in ral.vouchers:
self.assertEqual(voucher.status, "Reposted")
self.assertFalse(voucher.traceback)
def test_13_status_of_a_run_that_could_not_finish(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
with self.patched_repost(fail_for=["Payment Entry"]):
ral.submit()
ral.reload()
# the job dies after the loop committed the invoice, e.g. killed or timed out
try:
frappe.throw(SIMULATED_FAILURE)
except frappe.ValidationError:
_record_repost_failure(ral)
ral.reload()
# progress already committed must not be reported as a total failure
self.assertEqual(ral.status, "Partially Reposted")
self.assertIn(SIMULATED_FAILURE, ral.error_log)
self.assertTrue(
frappe.db.exists("Error Log", {"reference_doctype": ral.doctype, "reference_name": ral.name})
)
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_14_period_closed_after_the_repost_was_started(self):
gl = qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
si = self.make_invoice()
ral = self.create_repost_doc([si], submit=True)
ral.db_set("status", "Failed")
ral.vouchers[0].db_set("status", "Pending")
# the period is closed between the repost being started and the job running
self.make_period_closing_voucher()
gl_entries = frappe.db.count("GL Entry", {"voucher_no": si.name})
self.assertRaisesRegex(frappe.ValidationError, "Closed fiscal year", repost, ral.name, commit=False)
ral.reload()
self.assertEqual(ral.status, "Failed")
self.assertIn("Closed fiscal year", ral.error_log)
# the ledger is left exactly as it was
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": si.name}), gl_entries)
self.assertEqual(ral.vouchers[0].status, "Pending")
def test_15_failed_repost_skips_cancelled_voucher(self):
si = self.make_invoice()
ral = self.create_repost_doc([si])
with self.patched_repost(fail_for=["Sales Invoice"]):
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Failed")
si.reload()
si.cancel()
ral.start_repost()
ral.reload()
# nothing was reposted, but there is nothing left to repost either
self.assertEqual(ral.status, "Completed")
self.assertEqual(ral.vouchers[0].status, "Skipped")
self.assertFalse(ral.vouchers[0].traceback)
def test_16_concurrent_repost_is_blocked_by_voucher_lock(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
# a concurrent repost holding the lock on the second voucher
locked_pe = frappe.get_doc(pe.doctype, pe.name)
locked_pe.lock()
try:
self.assertRaises(frappe.DocumentLockedError, ral.submit)
# vouchers locked before the failure are released again
self.assertFalse(frappe.get_doc(si.doctype, si.name).is_locked)
finally:
locked_pe.unlock()
def test_17_journal_entry_repost(self):
je = make_journal_entry("_Test Bank - _TC", "_Test Cash - _TC", 500, submit=True)
je = frappe.get_doc("Journal Entry", je.name)
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
# without the deletion flag the 2 original entries are marked as cancelled,
# along with the 2 reverse entries booked against them
for delete_cancelled_entries, cancelled_entries in ((False, 4), (True, 0)):
with self.subTest(delete_cancelled_entries=delete_cancelled_entries):
ral = self.create_repost_doc(
[je], delete_cancelled_entries=delete_cancelled_entries, submit=True
)
self.assertEqual(ral.status, "Completed")
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
self.assertEqual(
frappe.db.count("GL Entry", {"voucher_no": je.name, "is_cancelled": 1}),
cancelled_entries,
)
def test_18_hook_allowed_doctype_repost(self):
class VoucherWithCancelArg:
doctype = "Test Repost Voucher"
name = "TRV-00001"
def __init__(self):
self.calls = []
def make_gl_entries(self, cancel=0):
self.calls.append(cancel)
class VoucherWithoutCancelArg(VoucherWithCancelArg):
def make_gl_entries(self):
self.calls.append("repost")
# vouchers that can reverse their own entries are asked to do so first
doc = VoucherWithCancelArg()
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
self.assertEqual(doc.calls, [1, 0])
# nothing to reverse when the old entries are deleted
doc = VoucherWithCancelArg()
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=True)
self.assertEqual(doc.calls, [0])
# the rest fall back to the generic reversal
doc = VoucherWithoutCancelArg()
with patch("erpnext.accounts.general_ledger.make_reverse_gl_entries") as make_reverse_gl_entries:
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
make_reverse_gl_entries.assert_called_once_with(voucher_type=doc.doctype, voucher_no=doc.name)
self.assertEqual(doc.calls, ["repost"])
def update_repost_settings():
allowed_types = [

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"creation": "2023-07-04 14:14:01.243848",
"doctype": "DocType",
@@ -7,34 +8,70 @@
"engine": "InnoDB",
"field_order": [
"voucher_type",
"voucher_no"
"column_break_ndex",
"voucher_no",
"reposting_status_section",
"status",
"traceback"
],
"fields": [
{
"columns": 5,
"fieldname": "voucher_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Voucher Type",
"options": "DocType"
"options": "DocType",
"reqd": 1
},
{
"fieldname": "column_break_ndex",
"fieldtype": "Column Break"
},
{
"columns": 5,
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Voucher No",
"options": "voucher_type"
"options": "voucher_type",
"reqd": 1
},
{
"fieldname": "reposting_status_section",
"fieldtype": "Section Break",
"label": "Reposting Status"
},
{
"columns": 2,
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"no_copy": 1,
"options": "Pending\nReposted\nSkipped\nFailed",
"read_only": 1
},
{
"fieldname": "traceback",
"fieldtype": "Code",
"label": "Traceback",
"no_copy": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:32.170897",
"modified": "2026-07-29 02:41:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger Items",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -17,8 +17,10 @@ class RepostAccountingLedgerItems(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None
status: DF.Literal["Pending", "Reposted", "Skipped", "Failed"]
traceback: DF.Code | None
voucher_no: DF.DynamicLink
voucher_type: DF.Link
# end: auto-generated types
pass

View File

@@ -517,6 +517,7 @@ class SalesInvoice(SellingController):
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
self.check_credit_limit()
self.check_overdue_billing_threshold()
if cint(self.is_pos) != 1 and not self.is_return:
self.update_against_document_in_jv()
@@ -778,6 +779,11 @@ class SalesInvoice(SellingController):
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
pos_invoice_doc.cancel()
def check_overdue_billing_threshold(self):
from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold
check_overdue_billing_threshold(self.customer, self.company)
@frappe.whitelist()
def set_missing_values(self, for_validate=False):
pos = self.set_pos_fields(for_validate)
@@ -3156,8 +3162,6 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
from frappe.model.mapper import get_mapped_doc
def postprocess_dunning(source, target):
from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
if dunning_type:
dunning_type = frappe.get_doc("Dunning Type", dunning_type)
@@ -3166,14 +3170,8 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
target.dunning_fee = dunning_type.dunning_fee
target.income_account = dunning_type.income_account
target.cost_center = dunning_type.cost_center
letter_text = get_dunning_letter_text(
dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
)
if letter_text:
target.body_text = letter_text.get("body_text")
target.closing_text = letter_text.get("closing_text")
target.language = letter_text.get("language")
target.language = source.language
target.get_dunning_letter_text()
# update outstanding from doc
if source.payment_schedule and len(source.payment_schedule) == 1:

View File

@@ -82,8 +82,7 @@
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"reqd": 1
"options": "Cost Center"
},
{
"fieldname": "shipping_amount_section",
@@ -141,19 +140,20 @@
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"icon": "fa fa-truck",
"idx": 1,
"links": [],
"modified": "2024-03-27 13:10:41.653314",
"modified": "2026-07-22 14:53:27.315435",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -197,7 +197,8 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -36,18 +36,17 @@ class ShippingRule(Document):
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
ShippingRuleCondition,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
ShippingRuleCountry,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
account: DF.Link
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
company: DF.Link
conditions: DF.Table[ShippingRuleCondition]
cost_center: DF.Link
cost_center: DF.Link | None
countries: DF.Table[ShippingRuleCountry]
disabled: DF.Check
label: DF.Data
project: DF.Link | None
shipping_amount: DF.Currency
shipping_rule_type: DF.Literal["Selling", "Buying"]
# end: auto-generated types
@@ -162,7 +161,14 @@ class ShippingRule(Document):
)
shipping_charge["add_deduct_tax"] = "Add"
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
shipping_charge_filters = shipping_charge.copy()
if not self.cost_center:
shipping_charge_filters["cost_center"] = (
"in",
(None, "", erpnext.get_default_cost_center(doc.company)),
)
existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters)
if existing_shipping_charge:
# take the last record found
existing_shipping_charge[-1].tax_amount = shipping_amount

View File

@@ -96,3 +96,29 @@ frappe.ui.form.on("Subscription", {
});
},
});
frappe.ui.form.on("Subscription Plan Detail", {
plan: function (frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (!row.plan) return;
const requested_plan = row.plan;
frappe.call({
method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions",
args: {
plan: requested_plan,
company: frm.doc.company,
party_type: frm.doc.party_type,
},
callback: function (r) {
if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return;
// Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten.
for (const [dimension, value] of Object.entries(r.message)) {
if (frm.fields_dict[dimension] && !frm.doc[dimension]) {
frm.set_value(dimension, value);
}
}
},
});
},
});

View File

@@ -25,6 +25,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.stock.doctype.item.item import get_item_defaults
class InvoiceCancelled(frappe.ValidationError):
@@ -801,6 +802,39 @@ def get_prorata_factor(
return diff / plan_days
@frappe.whitelist()
def get_plan_dimensions(
plan: str, company: str | None = None, party_type: str | None = None
) -> dict[str, str]:
"""Resolve a plan's accounting dimensions, falling back to the plan item's company defaults."""
plan_doc = frappe.get_cached_doc("Subscription Plan", plan)
dimensions = {}
for dimension in ["cost_center", *get_accounting_dimensions()]:
value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type)
if value:
dimensions[dimension] = value
return dimensions
def get_item_dimension(
item_code: str, dimension: str, company: str | None, party_type: str | None
) -> str | None:
if not company:
return None
item_defaults = get_item_defaults(item_code, company)
if dimension != "cost_center":
return item_defaults.get(dimension)
selling = item_defaults.get("selling_cost_center")
buying = item_defaults.get("buying_cost_center")
if party_type == "Supplier":
return buying or selling
return selling or buying
def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None:
"""
Task to updates the status of all `Subscription` apart from those that are cancelled

View File

@@ -17,7 +17,12 @@ from frappe.utils.data import (
)
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
from erpnext.accounts.doctype.subscription.subscription import (
Subscription,
get_plan_dimensions,
get_prorata_factor,
process_all,
)
from erpnext.accounts.utils import update_subscription_on_invoice_update
from erpnext.tests.utils import ERPNextTestSuite
@@ -804,6 +809,48 @@ class TestSubscription(ERPNextTestSuite):
)
self.assertEqual(len(subscription.invoices), 0)
def test_plan_dimensions_resolve_from_plan_then_item(self):
from erpnext.stock.doctype.item.test_item import make_item
# Plan-level cost center takes precedence.
create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR")
frappe.db.set_value(
"Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC"
)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"),
"_Test Cost Center - _TC",
)
# No plan cost center: fall back to the item's company default (selling vs buying by party type).
item = make_item(
"_Test Sub Dimension Item",
{
"is_stock_item": 0,
"item_defaults": [
{
"company": "_Test Company",
"default_warehouse": "_Test Warehouse - _TC",
"selling_cost_center": "_Test Cost Center - _TC",
"buying_cost_center": "_Test Cost Center 2 - _TC",
}
],
},
)
create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"),
"_Test Cost Center - _TC",
)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"),
"_Test Cost Center 2 - _TC",
)
# Without a company the item fallback is skipped.
self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
def make_plans():
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")

View File

@@ -854,11 +854,13 @@ def validate_account_party_type(self):
def get_dashboard_info(party_type, party, loyalty_program=None):
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
if not frappe.has_permission(doctype, "read"):
return None
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_all(
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)

View File

@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{
@@ -117,8 +117,11 @@ frappe.query_reports["Accounts Payable"] = {
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
options: "Supplier Group",
get_data: function (txt) {
return frappe.db.get_link_options("Supplier Group", txt);
},
hidden: 1,
},
{

View File

@@ -117,6 +117,36 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(len(report[1]), 2)
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
def test_supplier_group_filter(self):
pi = self.create_purchase_invoice()
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
other_group = frappe.get_doc(
doctype="Supplier Group",
supplier_group_name="_Test Supplier Group AP",
parent_supplier_group="All Supplier Groups",
).insert()
filters = {
"company": self.company,
"party_type": "Supplier",
"report_date": today(),
"range": "30, 60, 90, 120",
"supplier_group": supplier_group,
}
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
filters.update({"supplier_group": [other_group.name]})
self.assertEqual(len(execute(filters)[1]), 0)
filters.update({"supplier_group": [supplier_group, other_group.name]})
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
filters.update({"supplier_group": ["All Supplier Groups"]})
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
filters.update({"supplier_group": ["_Test Supplier Group Mars"]})
self.assertRaises(frappe.ValidationError, execute, filters)
def test_project_filter(self):
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{
@@ -100,8 +100,11 @@ frappe.query_reports["Accounts Payable Summary"] = {
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
options: "Supplier Group",
get_data: function (txt) {
return frappe.db.get_link_options("Supplier Group", txt);
},
},
{
fieldname: "based_on_payment_terms",

View File

@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{
@@ -140,8 +140,11 @@ frappe.query_reports["Accounts Receivable"] = {
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "group_by_party",

View File

@@ -55,8 +55,7 @@ class ReceivablePayableReport:
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
if "calculate_ageing_with" not in self.filters
or self.filters.calculate_ageing_with == "Today Date"
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
else self.filters.report_date
)
@@ -109,6 +108,7 @@ class ReceivablePayableReport:
def get_data(self):
self.get_sales_invoices_or_customers_based_on_sales_person()
self.get_invoices_based_on_sales_partner()
# Get invoice details like bill_no, due_date etc for all invoices
self.get_invoice_details()
@@ -244,6 +244,12 @@ class ReceivablePayableReport:
):
return
if self.filters.get("sales_partner"):
# a return is folded onto the invoice it settles, so match that invoice's
# partner (like the sales_person filter above), not the return's own
if ple.against_voucher_no not in self.sales_partner_invoices:
return
if self.filters.get("ignore_accounts"):
key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
else:
@@ -472,7 +478,7 @@ class ReceivablePayableReport:
"company": self.filters.company,
"docstatus": 1,
},
fields=["name", "due_date", "po_no"],
fields=["name", "due_date", "po_no", "sales_partner"],
)
for d in si_list:
self.invoice_details.setdefault(d.name, d)
@@ -910,6 +916,22 @@ class ReceivablePayableReport:
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
def get_invoices_based_on_sales_partner(self):
if not self.filters.get("sales_partner"):
return
self.sales_partner_invoices = set(
frappe.get_all(
"Sales Invoice",
filters={
"sales_partner": self.filters.get("sales_partner"),
"docstatus": 1,
"company": self.filters.company,
},
pluck="name",
)
)
def prepare_conditions(self):
self.qb_selection_filter = []
self.or_filters = []
@@ -997,7 +1019,13 @@ class ReceivablePayableReport:
self.qb_selection_filter.append(self.ple.party.isin(customers))
if self.filters.get("territory"):
self.get_hierarchical_filters("Territory", "territory")
territories = get_nested_set_children("Territory", self.filters.territory)
customers = (
qb.from_(self.customer)
.select(self.customer.name)
.where(self.customer["territory"].isin(territories))
)
self.qb_selection_filter.append(self.ple.party.isin(customers))
if self.filters.get("payment_terms_template"):
customer_ptt = self.ple.party.isin(
@@ -1012,26 +1040,16 @@ class ReceivablePayableReport:
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
if self.filters.get("sales_partner"):
self.qb_selection_filter.append(
self.ple.party.isin(
qb.from_(self.customer)
.select(self.customer.name)
.where(self.customer.default_sales_partner == self.filters.get("sales_partner"))
)
)
def exclude_employee_transaction(self):
self.qb_selection_filter.append(self.ple.party_type != "Employee")
def add_supplier_filters(self):
supplier = qb.DocType("Supplier")
if self.filters.get("supplier_group"):
groups = get_party_group_with_children("Supplier", self.filters.supplier_group)
self.qb_selection_filter.append(
self.ple.party.isin(
qb.from_(supplier)
.select(supplier.name)
.where(supplier.supplier_group == self.filters.get("supplier_group"))
qb.from_(supplier).select(supplier.name).where(supplier.supplier_group.isin(groups))
)
)
@@ -1083,16 +1101,6 @@ class ReceivablePayableReport:
return ptt
def get_hierarchical_filters(self, doctype, key):
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"])
doc = qb.DocType(doctype)
ple = self.ple
customer = self.customer
groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt))
customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups))
self.qb_selection_filter.append(ple.party.isin(customers))
def add_accounting_dimensions_filters(self):
accounting_dimensions = get_accounting_dimensions(as_list=False)
@@ -1120,9 +1128,6 @@ class ReceivablePayableReport:
if self.account_type == "Receivable":
fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
if self.filters.get("sales_partner"):
fields.append("default_sales_partner")
self.party_details[party] = frappe.db.get_value(
"Customer",
party,
@@ -1252,7 +1257,7 @@ class ReceivablePayableReport:
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
if self.filters.sales_partner:
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
if self.filters.account_type == "Payable":
self.add_column(
@@ -1339,19 +1344,23 @@ def get_party_group_with_children(party, party_groups):
if party not in ("Customer", "Supplier"):
return []
group_dtype = f"{party} Group"
if not isinstance(party_groups, list):
party_groups = [d.strip() for d in party_groups.strip().split(",") if d]
return get_nested_set_children(f"{party} Group", party_groups)
all_party_groups = []
for d in party_groups:
if frappe.db.exists(group_dtype, d):
lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"])
children = frappe.get_all(
group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name"
)
all_party_groups += children
def get_nested_set_children(doctype, values):
if not isinstance(values, list):
values = [d.strip() for d in values.split(",") if d.strip()]
if not values:
frappe.throw(_("Please select a valid {0}").format(_(doctype)))
all_values = []
for d in values:
if frappe.db.exists(doctype, d):
lft, rgt = frappe.db.get_value(doctype, d, ["lft", "rgt"])
children = frappe.get_all(doctype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name")
all_values += children
else:
frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d))
frappe.throw(_("{0}: {1} does not exist").format(doctype, d))
return list(set(all_party_groups))
return list(set(all_values))

View File

@@ -6,6 +6,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
@@ -778,6 +779,38 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
# Assert that the customer group of each row is in the list of customer groups
self.assertIn(row.customer_group, cus_groups_list)
def test_territory_filter(self):
self.create_sales_invoice()
territory = frappe.db.get_value("Customer", self.customer, "territory")
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"territory": territory,
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
self.assertEqual(
[100.0, 100.0, territory], [report[0].invoiced, report[0].outstanding, report[0].territory]
)
filters.update({"territory": ["_Test Territory United States"]})
self.assertEqual(len(execute(filters)[1]), 0)
filters.update({"territory": [territory, "_Test Territory United States"]})
self.assertEqual(len(execute(filters)[1]), 1)
frappe.db.set_value("Customer", self.customer, "territory", "_Test Territory Maharashtra")
filters.update({"territory": ["_Test Territory India"]})
self.assertEqual(len(execute(filters)[1]), 1)
filters.update({"territory": ["_Test Territory Mars"]})
self.assertRaises(frappe.ValidationError, execute, filters)
filters.update({"territory": " "})
self.assertRaises(frappe.ValidationError, execute, filters)
def test_party_account_filter(self):
si1 = self.create_sales_invoice()
jane = frappe.get_doc(
@@ -1292,3 +1325,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
self.assertIn(original_customer, parties)
self.assertNotIn(second_customer, parties)
self.assertEqual(allowed_invoice.customer, original_customer)
def test_receivable_filtered_by_sales_partner(self):
frappe.set_user("Administrator")
partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B"
for partner in (partner_a, partner_b):
if not frappe.db.exists("Sales Partner", partner):
frappe.get_doc(
{
"doctype": "Sales Partner",
"partner_name": partner,
"commission_rate": 0,
"territory": "All Territories",
}
).insert()
def _si(sales_partner):
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)
si.sales_partner = sales_partner
return si.save().submit()
partner_a_si = _si(partner_a)
partner_b_si = _si(partner_b)
no_partner_si = _si(None)
# a return is folded onto the invoice it settles, so it nets against that
# invoice's partner even when the return's own partner is cleared
no_partner_return = make_return_doc("Sales Invoice", partner_a_si.name)
no_partner_return.sales_partner = None
no_partner_return.items[0].qty = -1
no_partner_return.update_outstanding_for_self = 0
no_partner_return.save().submit()
filters = {
"company": self.company,
"party_type": "Customer",
"report_date": today(),
"range": "30, 60, 90, 120",
}
def rows_for(partner):
return {
r.voucher_no: r
for r in execute({**filters, "sales_partner": partner})[1]
if r.get("voucher_no")
}
rows_a = rows_for(partner_a)
self.assertIn(partner_a_si.name, rows_a)
self.assertEqual(rows_a[partner_a_si.name].sales_partner, partner_a)
self.assertNotIn(partner_b_si.name, rows_a)
self.assertNotIn(no_partner_si.name, rows_a)
self.assertNotIn(no_partner_return.name, rows_a)
self.assertEqual(rows_a[partner_a_si.name].credit_note, 100)
self.assertEqual(rows_a[partner_a_si.name].outstanding, 100)
rows_b = rows_for(partner_b)
self.assertIn(partner_b_si.name, rows_b)
self.assertNotIn(partner_a_si.name, rows_b)

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{
@@ -106,8 +106,11 @@ frappe.query_reports["Accounts Receivable Summary"] = {
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "sales_partner",

View File

@@ -132,8 +132,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
if row.sales_person:
self.party_total[row.party].sales_person.append(row.get("sales_person", ""))
if self.filters.sales_partner:
self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "")
if self.filters.sales_partner and row.get("sales_partner"):
self.party_total[row.party]["sales_partner"] = row.get("sales_partner")
def get_columns(self):
self.columns = []
@@ -191,7 +191,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
if self.filters.sales_partner:
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
else:
self.add_column(

View File

@@ -191,3 +191,42 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
rpt_output = report[1]
self.assertEqual(len(rpt_output), 0)
def test_03_summary_sales_partner_column(self):
partner = "_Test AR Summary Sales Partner"
if not frappe.db.exists("Sales Partner", partner):
frappe.get_doc(
{
"doctype": "Sales Partner",
"partner_name": partner,
"commission_rate": 0,
"territory": "All Territories",
}
).insert()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=200,
price_list_rate=200,
do_not_submit=True,
)
si.sales_partner = partner
si.save().submit()
filters = {
"company": self.company,
"customer": self.customer,
"posting_date": today(),
"range": "30, 60, 90, 120",
"sales_partner": partner,
}
rpt_output = execute(filters)[1]
self.assertEqual(len(rpt_output), 1)
self.assertEqual(rpt_output[0].get("sales_partner"), partner)

View File

@@ -80,6 +80,7 @@ def execute(filters=None):
"parent_section": None,
"indent": 0.0,
"section": cash_flow_section["section_header"],
"currency": company_currency,
}
)

View File

@@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
)
if total_base_amount
else 0,
"currency": filters.currency,
}
)
)
@@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
"buying_amount": total_buying_amount,
"gross_profit": total_gross_profit,
"gross_profit_percent": flt(gross_profit_percent, currency_precision),
"currency": filters.currency,
}
total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]]

View File

@@ -5,6 +5,8 @@ import frappe
from frappe import _
from frappe.query_builder.functions import IfNull
from erpnext.accounts.report.utils import validate_mandatory_date_range
class TaxWithholdingDetailsReport:
party_types = ("Customer", "Supplier")
@@ -25,11 +27,7 @@ class TaxWithholdingDetailsReport:
return self.get_columns(), self.get_data()
def validate_filters(self):
if not self.filters.from_date or not self.filters.to_date:
frappe.throw(_("From Date and To Date are required"))
if self.filters.from_date > self.filters.to_date:
frappe.throw(_("From Date must be before To Date"))
validate_mandatory_date_range(self.filters)
def get_data(self):
self.entries = self.get_entries_query().run(as_dict=True)

View File

@@ -21,8 +21,7 @@ class TDSComputationSummaryReport(TaxWithholdingDetailsReport):
AGGREGATE_FIELDS = ("total_amount", "tax_amount")
def validate_filters(self):
if self.filters.from_date > self.filters.to_date:
frappe.throw(_("From Date must be before To Date"))
super().validate_filters()
from_year = get_fiscal_year(self.filters.from_date)[0]
to_year = get_fiscal_year(self.filters.to_date)[0]

View File

@@ -1,4 +1,5 @@
import frappe
from frappe import _
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import flt, formatdate, get_datetime_str, get_table_name
@@ -16,6 +17,19 @@ from erpnext.setup.utils import get_exchange_rate
__exchange_rates = {}
def validate_mandatory_date_range(filters, from_field="from_date", to_field="to_date"):
from_date = filters.get(from_field)
to_date = filters.get(to_field)
if not from_date or not to_date:
frappe.throw(
_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date")))
)
if from_date > to_date:
frappe.throw(_("From Date must be before To Date"))
def get_currency(filters):
"""
Returns a dictionary containing currency information. The keys of the dict are

View File

@@ -1332,7 +1332,7 @@ def has_active_capitalization(asset):
@frappe.whitelist()
def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
def get_values_from_purchase_doc(purchase_doc_name: str, item_code: str, doctype: str):
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
@@ -1344,7 +1344,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
return {
"company": purchase_doc.company,
"purchase_date": purchase_doc.get("posting_date"),
"net_purchase_amount": flt(first_item.base_net_amount),
"net_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
"asset_quantity": first_item.qty,
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
"asset_location": first_item.get("asset_location"),

View File

@@ -668,11 +668,13 @@ def get_target_asset_details(asset: str | None = None, company: str | None = Non
@frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx)
def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
frappe.has_permission("Stock Ledger Entry", throw=True)
out = frappe._dict()
item = frappe._dict()
if ctx.item_code:
item = frappe.get_cached_doc("Item", ctx.item_code)
item.check_permission()
out.item_name = item.item_name
out.batch_no = None
@@ -682,6 +684,8 @@ def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
out.stock_uom = item.stock_uom
out.warehouse = get_item_warehouse_(ctx, item, overwrite_warehouse=True) if item else None
if out.warehouse:
frappe.has_permission("Warehouse", doc=out.warehouse, throw=True)
# Cost Center
item_defaults = get_item_defaults(item.name, ctx.company)
@@ -722,6 +726,9 @@ def get_warehouse_details(args):
out = {}
if args.warehouse and args.item_code:
frappe.has_permission("Item", doc=args.item_code, throw=True)
frappe.has_permission("Warehouse", doc=args.warehouse, throw=True)
frappe.has_permission("Stock Ledger Entry", throw=True)
out = {
"actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0,
"valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False),

View File

@@ -162,6 +162,21 @@ class TestPurchaseOrder(ERPNextTestSuite):
po2.items[0].qty = 110
self.assertRaises(OverAllowanceError, po2.submit)
# Stock over-delivery role must not bypass over-ordering against Material Request.
with self.change_settings(
"Stock Settings", {"role_allowed_to_over_deliver_receive": "Stock Manager"}
):
test_user = frappe.get_doc("User", "test@example.com")
test_user.add_roles("Stock Manager")
mr3 = make_material_request(qty=100)
po3 = make_purchase_order(mr3.name)
po3.supplier = "_Test Supplier"
po3.items[0].qty = 110
with self.set_user("test@example.com"):
po3.flags.ignore_permissions = True
self.assertRaises(OverAllowanceError, po3.submit)
# cleanup
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
@@ -996,6 +1011,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
compare_payment_schedules(self, po, pi)
@ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 1})
def test_internal_transfer_flow(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
@@ -1007,9 +1024,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
)
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
prepare_data_for_internal_transfer()
supplier = "_Test Internal Supplier 2"
@@ -1448,6 +1462,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(pi_2.status, "Paid")
self.assertEqual(po.status, "Completed")
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 0})
def test_purchase_order_over_billing_missing_item(self):
item1 = make_item(
"_Test Item for Overbilling",

View File

@@ -923,8 +923,10 @@
"fieldname": "job_card",
"fieldtype": "Link",
"label": "Job Card",
"no_copy": 1,
"options": "Job Card",
"search_index": 1
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "distributed_discount_amount",
@@ -951,7 +953,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-14 12:16:16.192936",
"modified": "2026-07-15 10:30:04.600510",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -16,7 +16,10 @@ from erpnext.accounts.party import (
validate_party_accounts,
validate_party_currency_before_merging,
)
from erpnext.controllers.website_list_for_contact import add_role_for_portal_user
from erpnext.controllers.website_list_for_contact import (
add_role_for_portal_user,
link_portal_users_to_contacts,
)
from erpnext.utilities.transaction_base import TransactionBase
@@ -109,6 +112,7 @@ class Supplier(TransactionBase):
def on_update(self):
self.create_primary_contact()
self.create_primary_address()
link_portal_users_to_contacts(self)
def add_role_for_user(self):
for portal_user in self.portal_users:

View File

@@ -203,3 +203,24 @@ class TestSupplierPortal(ERPNextTestSuite):
_, suppliers = get_customers_suppliers("Purchase Order", user)
self.assertIn(supplier.name, suppliers)
def test_portal_user_contact_link(self):
user_email = frappe.generate_hash() + "@example.com"
user = frappe.new_doc("User")
user.email = user_email
user.first_name = "Test Portal Contact User"
user.send_welcome_email = False
user.insert(ignore_permissions=True)
contact = frappe.new_doc("Contact")
contact.first_name = "Test Portal Contact User"
contact.add_email(user_email, is_primary=1)
contact.links = []
contact.insert(ignore_permissions=True)
supplier = create_supplier()
supplier.append("portal_users", {"user": user.name})
supplier.save()
contact.reload()
self.assertTrue(contact.has_link("Supplier", supplier.name))

View File

@@ -307,6 +307,7 @@
"fieldname": "net_rate",
"fieldtype": "Currency",
"label": "Net Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
@@ -613,7 +614,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-17 12:05:52.441645",
"modified": "2026-07-15 10:33:24.855979",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",

View File

@@ -14,7 +14,6 @@ def execute(filters=None):
conditions = get_columns(filters, "Purchase Order")
data = get_data(filters, conditions)
chart_data = get_chart_data(data, conditions, filters)
return conditions["columns"], data, None, chart_data
@@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters):
labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns]
datapoints = [0] * len(labels)
group_by_col_idx = None
if filters.get("group_by"):
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
for row in data:
# If group by filter, don't add first row of group (it's already summed)
if not row[start]:
# Skip the final grand-total row
if row[0] == f"'{_('Total')}'":
continue
if group_by_col_idx is not None and row[group_by_col_idx] == "":
continue
# Remove None values and compute only periodic data
row = [x if x else 0 for x in row[start:-2]]
@@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters):
"type": "line",
"lineOptions": {"regionFill": 1},
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

View File

@@ -1893,7 +1893,7 @@ class AccountsController(TransactionBase):
def is_payable_account(self, reference_doctype, account):
if reference_doctype == "Purchase Invoice" or (
reference_doctype == "Journal Entry"
reference_doctype in ("Journal Entry", "Payment Entry")
and frappe.get_cached_value("Account", account, "account_type") == "Payable"
):
return True
@@ -3873,6 +3873,7 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
for d in deleted_children:
validate_child_on_delete(d, parent, ordered_item)
d.flags.ignore_permissions = True
d.cancel()
d.delete()

View File

@@ -331,32 +331,51 @@ class BuyingController(SubcontractingController):
address_display_field, render_address(self.get(address_field), check_permissions=False)
)
def get_validated_purchase_expense_details(self, item_code):
fields = ("purchase_expense_account", "purchase_expense_contra_account")
details = get_purchase_expense_account(item_code, self.company)
for field in fields:
if not details.get(field):
details[field] = frappe.get_cached_value("Company", self.company, field)
for field in fields:
if not details.get(field):
frappe.throw(
_("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format(
frappe.bold(_(frappe.unscrub(field))), self.company, item_code
)
)
return details
def set_gl_entry_for_purchase_expense(self, gl_entries):
if not cint(frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")):
return
if self.doctype == "Purchase Invoice" and not self.update_stock:
return
stock_items = self.get_stock_items()
for row in self.items:
details = get_purchase_expense_account(row.item_code, self.company)
# A service item holds no stock value, so there is nothing to book against it - and it
# must not make the expense accounts mandatory either.
if row.item_code not in stock_items:
continue
if not details.purchase_expense_account:
details.purchase_expense_account = frappe.get_cached_value(
"Company", self.company, "purchase_expense_account"
)
if not details.purchase_expense_account:
return
if not details.purchase_expense_contra_account:
details.purchase_expense_contra_account = frappe.get_cached_value(
"Company", self.company, "purchase_expense_contra_account"
)
if not details.purchase_expense_contra_account:
frappe.throw(
_("Please set Purchase Expense Contra Account in Company {0}").format(self.company)
)
details = self.get_validated_purchase_expense_details(row.item_code)
if not details:
continue
amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount"))
if row.landed_cost_voucher_amount:
amount -= flt(row.landed_cost_voucher_amount, row.precision("base_amount"))
if not amount:
# GL Entry rejects a row with neither a debit nor a credit.
continue
self.add_gl_entry(
gl_entries=gl_entries,
account=details.purchase_expense_account,
@@ -448,7 +467,7 @@ class BuyingController(SubcontractingController):
self.precision("item_tax_amount", item),
)
self.round_floats_in(item)
self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
if flt(item.conversion_factor) == 0.0:
item.conversion_factor = (
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0

View File

@@ -336,6 +336,7 @@ def create_variant(item, args, use_template_image=False):
@frappe.whitelist()
def enqueue_multiple_variant_creation(item, args, use_template_image=False):
frappe.has_permission("Item", ptype="create", throw=True)
use_template_image = frappe.parse_json(use_template_image)
# There can be innumerable attribute combinations, enqueue
if isinstance(args, str):

View File

@@ -332,7 +332,9 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_project_name(doctype, txt, searchfield, start, page_len, filters):
def get_project_name(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
):
proj = qb.DocType("Project")
qb_filter_and_conditions = []
qb_filter_or_conditions = []
@@ -347,7 +349,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
if filters.get("company"):
qb_filter_and_conditions.append(proj.company == filters.get("company"))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"]))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
q = qb.from_(proj)

View File

@@ -159,10 +159,28 @@ def validate_returned_items(doc):
):
frappe.throw(_("Warehouse is mandatory"))
items_returned = True
if doc.doctype in (
"Purchase Invoice",
"Purchase Receipt",
"Subcontracting Receipt",
"Sales Invoice",
"Delivery Note",
"POS Invoice",
):
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
items_returned = True
else:
items_returned = True
elif d.item_name:
items_returned = True
if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"):
# No item_code here means no linked Item, so there's no accepted/rejected
# split to speak of - received_qty isn't a meaningful independent signal.
# Only a negative qty (i.e. a real negative billing amount) counts.
if flt(d.qty) < 0:
items_returned = True
else:
items_returned = True
if not items_returned:
frappe.throw(_("At least one item should be entered with negative quantity in return document"))

View File

@@ -445,11 +445,12 @@ class StatusUpdater(Document):
else (0, {}, None, None)
)
role_allowed_to_over_deliver_receive = frappe.get_single_value(
"Stock Settings", "role_allowed_to_over_deliver_receive"
)
role_allowed_to_over_bill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill
role = None
if qty_or_amount == "qty":
if args.get("overflow_type") in ("delivery", "receipt"):
role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive")
else:
role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
overflow_percent = (
(item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]]

View File

@@ -70,9 +70,23 @@ QI_OUTGOING_PURPOSES = (
)
SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
def is_inspection_exempt_secondary_row(doc, row) -> bool:
"""Whether the row is a secondary item on a document that produces secondary items."""
if not (row.get("type") or row.get("is_legacy_scrap_item")):
return False
if doc.doctype == "Stock Entry":
return doc.purpose in SECONDARY_ITEM_PURPOSES
return True
def stock_entry_row_requires_inspection(purpose, row):
"""Check if this Stock Entry row need a Quality Inspection."""
if row.get("type") or row.get("is_legacy_scrap_item"):
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("type") or row.get("is_legacy_scrap_item")):
return False
if purpose == "Manufacture":
return bool(row.is_finished_item)
@@ -84,6 +98,11 @@ def stock_entry_row_requires_inspection(purpose, row):
class StockController(AccountsController):
#: Vouchers whose stock value change should also be booked to the Expenses Added To Stock
#: account pair (Stock Entry, Stock Reconciliation). Purchase Receipt books its own, against
#: the landed cost amount rather than the stock value difference.
book_expenses_added_to_stock = False
def validate(self):
super().validate()
@@ -858,10 +877,90 @@ class StockController(AccountsController):
).format(wh, self.company)
)
if self.book_expenses_added_to_stock:
self.append_expenses_added_to_stock_entries(gl_list, voucher_details, sle_map)
return process_gl_map(
gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation
)
def book_stock_expense_enabled(self):
if not hasattr(self, "_book_stock_expense_enabled"):
self._book_stock_expense_enabled = cint(
frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")
)
return self._book_stock_expense_enabled
def append_expenses_added_to_stock_entries(self, gl_list, voucher_details, sle_map):
if not self.book_stock_expense_enabled():
return
precision = self.get_debit_field_precision()
for item_row in voucher_details:
sle_list = sle_map.get(item_row.name)
if not sle_list:
continue
amount = flt(sum(flt(sle.stock_value_difference) for sle in sle_list), precision)
if not amount:
continue
item_code = item_row.get("item_code") or sle_list[0].item_code
self.append_expenses_added_to_stock_pair(gl_list, item_code, amount, item_row)
def append_expenses_added_to_stock_pair(self, gl_list, item_code, amount, item_row):
# A service item holds no stock value, so there is nothing to book against it - and it must
# not make the expense accounts mandatory either. A zero pair would be rejected by GL Entry
# anyway, which needs a debit or a credit on every row.
if not amount or not frappe.get_cached_value("Item", item_code, "is_stock_item"):
return
fields = ("expenses_added_to_stock_account", "expenses_added_to_stock_contra_account")
details = get_expenses_added_to_stock_accounts(item_code, self.company)
for field in fields:
if not details.get(field):
frappe.throw(
_("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format(
frappe.bold(_(frappe.unscrub(field))), self.company, item_code
)
)
cost_center = item_row.get("cost_center") or frappe.get_cached_value(
"Company", self.company, "cost_center"
)
remarks = _("Expenses Added To Stock for Item {0}").format(item_code)
common_args = {
"cost_center": cost_center,
"project": item_row.get("project") or self.get("project"),
"remarks": remarks,
}
gl_list.append(
self.get_gl_dict(
{
"account": details.expenses_added_to_stock_account,
"against": details.expenses_added_to_stock_contra_account,
"debit": amount,
**common_args,
},
item=item_row,
)
)
gl_list.append(
self.get_gl_dict(
{
"account": details.expenses_added_to_stock_contra_account,
"against": details.expenses_added_to_stock_account,
"debit": -1 * amount,
**common_args,
},
item=item_row,
)
)
def get_debit_field_precision(self):
if not frappe.flags.debit_field_precision:
frappe.flags.debit_field_precision = frappe.get_precision("GL Entry", "debit_in_account_currency")
@@ -1347,66 +1446,64 @@ class StockController(AccountsController):
if not batches:
return
field_mapper = {
"Sales Invoice": [["Sales Order", "sales_order"]],
"Delivery Note": [["Sales Order", "against_sales_order"]],
"Stock Entry": [
["Work Order", "work_order"],
["Subcontracting Inward Order", "subcontracting_inward_order"],
],
reference_fields = {
"Sales Invoice": ["sales_order"],
"Delivery Note": ["against_sales_order"],
"Stock Entry": ["work_order", "subcontracting_inward_order"],
}.get(self.doctype)
qty_field = {
"Sales Invoice": "qty",
"Delivery Note": "qty",
"Stock Entry": "fg_completed_qty",
}.get(self.doctype)
reserved_batches_data = self.get_reserved_batches(batches)
items = self.items
if self.doctype == "Stock Entry":
items = [self]
for item in items:
for field in field_mapper:
if not item.get(field[1]):
continue
own_vouchers = {item.get(field) for item in items for field in reference_fields if item.get(field)}
value = item.get(field[1])
for row in reserved_batches_data:
if self.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get(
"item_code"
):
continue
outstanding_qty = defaultdict(float)
reservations = defaultdict(list)
for row in self.get_reserved_batches(batches):
if row.voucher_no in own_vouchers:
continue
if row.voucher_no == value:
continue
key = (row.batch_no, row.warehouse)
outstanding = flt(row.qty) - flt(row.delivered_qty)
outstanding_qty[key] += outstanding
if outstanding > 0:
reservations[key].append(row)
batch_qty = get_batch_qty(
row.batch_no,
row.warehouse,
posting_date=self.posting_date,
posting_time=self.posting_time,
consider_negative_batches=True,
)
precision = frappe.get_precision("Serial and Batch Entry", "qty")
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
if flt(reserved_qty, precision) <= 0:
continue
if item.get(qty_field) < batch_qty:
continue
batch_qty = get_batch_qty(
batch_no,
warehouse,
posting_date=self.posting_date,
posting_time=self.posting_time,
consider_negative_batches=True,
)
frappe.throw(
_(
"The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}."
).format(
frappe.bold(row.batch_no),
frappe.bold(row.voucher_type),
frappe.bold(row.voucher_no),
frappe.bold(self.doctype),
frappe.bold(self.name),
frappe.bold(field[0]),
frappe.bold(value),
),
title=_("Reserved Batch Conflict"),
)
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
continue
vouchers = ", ".join(
f"{frappe.bold(voucher_type)} {frappe.bold(voucher_no)}"
for voucher_type, voucher_no in dict.fromkeys(
(row.voucher_type, row.voucher_no) for row in reservations[(batch_no, warehouse)]
)
)
frappe.throw(
_(
"The batch {0} is reserved for {1} in the warehouse {2} and the remaining quantity is not enough to cover the reservations. So, cannot proceed with the {3} {4}."
).format(
frappe.bold(batch_no),
vouchers,
frappe.bold(warehouse),
frappe.bold(self.doctype),
frappe.bold(self.name),
),
title=_("Reserved Batch Conflict"),
)
def get_reserved_batches(self, batches):
doctype = frappe.qb.DocType("Stock Reservation Entry")
@@ -1418,9 +1515,10 @@ class StockController(AccountsController):
.on(doctype.name == child_doc.parent)
.select(
child_doc.batch_no,
child_doc.qty,
child_doc.delivered_qty,
doctype.voucher_type,
doctype.voucher_no,
doctype.item_code,
doctype.warehouse,
)
.where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches)))
@@ -1520,7 +1618,7 @@ class StockController(AccountsController):
elif self.doctype == "Stock Entry":
qi_required = stock_entry_row_requires_inspection(self.purpose, row)
if row.get("type") or row.get("is_legacy_scrap_item"):
if is_inspection_exempt_secondary_row(self, row):
continue
if qi_required: # validate row only if inspection is required on item level
@@ -2057,7 +2155,7 @@ class StockController(AccountsController):
@frappe.whitelist()
def show_accounting_ledger_preview(company, doctype, docname):
def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
filters = frappe._dict(company=company, include_dimensions=1)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")
@@ -2071,7 +2169,7 @@ def show_accounting_ledger_preview(company, doctype, docname):
@frappe.whitelist()
def show_stock_ledger_preview(company, doctype, docname):
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
filters = frappe._dict(company=company)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")
@@ -2569,3 +2667,31 @@ def get_item_wise_inventory_account_map(rows, company):
)
return inventory_map
@frappe.request_cache
def get_expenses_added_to_stock_accounts(item_code, company):
"""Resolves the Expenses Added To Stock account pair for an item, falling back through
Item Defaults -> Item Group -> Brand -> Company."""
from erpnext.stock.doctype.item.item import get_item_defaults
fields = ["expenses_added_to_stock_account", "expenses_added_to_stock_contra_account"]
defaults = get_item_defaults(item_code, company)
details = frappe._dict({field: defaults.get(field) for field in fields})
if not details.expenses_added_to_stock_account:
details = frappe.db.get_value(
"Item Default", {"parent": defaults.item_group, "company": company}, fields, as_dict=1
) or frappe._dict({})
if not details.expenses_added_to_stock_account and defaults.get("brand"):
details = frappe.db.get_value(
"Item Default", {"parent": defaults.brand, "company": company}, fields, as_dict=1
) or frappe._dict({})
for field in fields:
if not details.get(field):
details[field] = frappe.get_cached_value("Company", company, field)
return details

View File

@@ -227,7 +227,12 @@ class calculate_taxes_and_totals:
if self.doc.get("is_consolidated") or self.discount_amount_applied:
return
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
do_not_round_fields = [
"valuation_rate",
"incoming_rate",
"sales_incoming_rate",
"conversion_factor",
]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
self.calculate_item_rate(item)

View File

@@ -0,0 +1,90 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesAndPurchaseReturn(ERPNextTestSuite):
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def test_purchase_invoice_zero_qty_return_is_rejected(self):
# A return with every item at qty 0 moves no stock and no value, so it must be
# rejected the same way a return with no items at all would be.
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(qty=10)
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
return_pi = make_purchase_invoice(
is_return=1,
return_against=pi.name,
qty=0,
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, return_pi.save)
def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self):
# Item Code is not mandatory on Purchase Invoice Item - a row can have only an
# item_name (e.g. a free-text/non-stock line). Such rows fall through to the
# item_name-only branch, which must also reject an all-zero-qty return instead
# of unconditionally treating the row as returned.
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
pi.items[0].item_code = ""
pi.save()
pi.submit()
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
return_pi = make_purchase_invoice(
item_name="_Test Item",
is_return=1,
return_against=pi.name,
qty=0,
do_not_save=True,
)
return_pi.items[0].item_code = ""
self.assertRaises(frappe.ValidationError, return_pi.save)
def test_delivery_note_zero_qty_return_is_rejected(self):
# A return with every item at qty 0 moves no stock and no value, so it must be
# rejected the same way a return with no items at all would be.
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
dn = create_delivery_note(qty=5)
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
return_dn = make_sales_return(dn.name)
return_dn.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_dn.insert)
def test_sales_invoice_zero_qty_return_is_rejected(self):
# Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on
# every row must be rejected, not silently accepted as a no-op credit note.
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.controllers.sales_and_purchase_return import make_return_doc
si = create_sales_invoice(qty=10)
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
return_si = make_return_doc(si.doctype, si.name)
return_si.items[0].qty = 0
self.assertRaises(frappe.ValidationError, return_si.save)

View File

@@ -6,6 +6,7 @@ import frappe
from frappe import _
from frappe.utils import DateTimeLikeObject, getdate, today
import erpnext
from erpnext.accounts.utils import get_fiscal_year
@@ -42,6 +43,9 @@ def get_columns(filters, trans):
"addl_tables": based_on_details["addl_tables"],
"addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""),
}
conditions["company_currency"] = (
erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None
)
return conditions
@@ -206,7 +210,7 @@ def get_data(filters, conditions):
data.append(des)
total_row = calculate_total_row(data1, conditions["columns"])
total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency"))
data.append(total_row)
else:
data = frappe.db.sql(
@@ -231,19 +235,24 @@ def get_data(filters, conditions):
as_list=1,
)
total_row = calculate_total_row(data, conditions["columns"])
total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency"))
data.append(total_row)
return data
def calculate_total_row(data, columns):
def calculate_total_row(data, columns, company_currency=None):
def wrap_in_quotes(label):
return f"'{label}'"
total_values = {}
currency_col_idx = None
for i, col in enumerate(columns):
if "Float" in col or "Currency/currency" in col:
# based-on and group-by columns are dicts, periodic and total columns are strings
if isinstance(col, dict):
if col.get("fieldtype") == "Link" and col.get("options") == "Currency":
currency_col_idx = i
elif "Float" in col or "Currency/currency" in col:
total_values[i] = 0
for row in data:
@@ -254,6 +263,9 @@ def calculate_total_row(data, columns):
for i in range(1, len(columns)):
total_row.append(total_values.get(i, None))
if currency_col_idx is not None:
total_row[currency_col_idx] = company_currency
return total_row

View File

@@ -7,6 +7,8 @@ import json
import frappe
from frappe import _
from frappe.modules.utils import get_module_app
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Lower
from frappe.utils import cint, flt, has_common
from frappe.utils.user import is_website_user
@@ -306,3 +308,63 @@ def add_role_for_portal_user(portal_user, role):
user_doc.add_roles(role)
frappe.msgprint(_("Added {1} Role to User {0}.").format(frappe.bold(user_doc.name), role), alert=True)
def link_portal_users_to_contacts(doc):
"""When portal users are added to Supplier/Customer, link them to the Contact profile."""
# a User's name is its (lowercased) email, so portal_users are already the emails
portal_users = {p.user for p in doc.get("portal_users") or [] if p.user}
if not portal_users:
return
before = doc.get_doc_before_save()
if before:
previous_users = {p.user for p in before.get("portal_users") or [] if p.user}
if portal_users == previous_users:
return
portal_users = list(portal_users)
contact = frappe.qb.DocType("Contact")
contact_email = frappe.qb.DocType("Contact Email")
query = (
frappe.qb.from_(contact)
.left_join(contact_email)
.on(contact_email.parent == contact.name)
.select(contact.name)
.distinct()
)
conditions = [
contact.user.isin(portal_users),
Lower(contact.email_id).isin(portal_users),
Lower(contact_email.email_id).isin(portal_users),
]
query = query.where(Criterion.any(conditions))
contacts = query.run(pluck=True)
if not contacts:
return
dynamic_link = frappe.qb.DocType("Dynamic Link")
existing_links = (
frappe.qb.from_(dynamic_link)
.select(dynamic_link.parent)
.where(
(dynamic_link.parenttype == "Contact")
& (dynamic_link.parent.isin(contacts))
& (dynamic_link.link_doctype == doc.doctype)
& (dynamic_link.link_name == doc.name)
)
.run(pluck=True)
)
contacts_to_link = [name for name in contacts if name not in existing_links]
for name in contacts_to_link:
contact_doc = frappe.get_doc("Contact", name)
if not contact_doc.has_link(doc.doctype, doc.name):
contact_doc.append("links", {"link_doctype": doc.doctype, "link_name": doc.name})
contact_doc.save(ignore_permissions=True)

View File

@@ -7,7 +7,11 @@
"engine": "InnoDB",
"field_order": [
"scheduled_time",
"column_break_xaox",
"status",
"created_through_portal",
"email_verified",
"verification_token",
"customer_details_section",
"customer_name",
"customer_phone_number",
@@ -54,7 +58,8 @@
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Scheduled Time",
"reqd": 1
"reqd": 1,
"search_index": 1
},
{
"fieldname": "status",
@@ -77,8 +82,8 @@
"fieldname": "customer_email",
"fieldtype": "Data",
"label": "Email",
"reqd": 1,
"options": "Email"
"options": "Email",
"reqd": 1
},
{
"fieldname": "linked_docs_section",
@@ -100,13 +105,43 @@
"fieldtype": "Dynamic Link",
"label": "Party",
"options": "appointment_with"
},
{
"default": "0",
"fieldname": "created_through_portal",
"fieldtype": "Check",
"label": "Created through Portal",
"read_only": 1,
"set_only_once": 1
},
{
"fieldname": "column_break_xaox",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "eval:doc.created_through_portal === 1;",
"fieldname": "email_verified",
"fieldtype": "Check",
"label": "Email Verified",
"read_only": 1
},
{
"fieldname": "verification_token",
"fieldtype": "Data",
"label": "Verification Token",
"hidden": 1,
"read_only": 1,
"no_copy": 1,
"search_index": 1
}
],
"links": [],
"modified": "2026-06-06 13:05:59.300573",
"modified": "2026-07-20 02:00:00.000000",
"modified_by": "Administrator",
"module": "CRM",
"name": "Appointment",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -158,8 +193,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -3,14 +3,20 @@
from collections import Counter
from datetime import timedelta
from urllib.parse import urlencode
import frappe
from frappe import _
from frappe.desk.form.assign_to import add as add_assignment
from frappe.model.document import Document
from frappe.share import add_docshare
from frappe.utils import get_url, getdate, now
from frappe.utils.verified_command import get_signed_params
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
from frappe.utils.data import sha256_hash
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
class Appointment(Document):
@@ -24,104 +30,227 @@ class Appointment(Document):
appointment_with: DF.Link | None
calendar_event: DF.Link | None
created_through_portal: DF.Check
customer_details: DF.LongText | None
customer_email: DF.Data
customer_name: DF.Data
customer_phone_number: DF.Data | None
customer_skype: DF.Data | None
email_verified: DF.Check
party: DF.DynamicLink | None
scheduled_time: DF.Datetime
status: DF.Literal["Open", "Unverified", "Closed"]
verification_token: DF.Data | None
# end: auto-generated types
def find_lead_by_email(self):
lead_list = frappe.get_list(
"Lead", filters={"email_id": self.customer_email}, ignore_permissions=True
)
if lead_list:
return lead_list[0].name
return None
def validate(self):
self.validate_status_update()
if not self.has_value_changed("scheduled_time"):
return
def find_customer_by_email(self):
customer_list = frappe.get_list(
"Customer", filters={"email_id": self.customer_email}, ignore_permissions=True
self.validate_backdated_booking()
if is_appointment_scheduling_enabled():
self.validate_advanced_booking()
self.validate_holiday()
self.validate_slot_timing()
self.validate_available_time_slot()
def validate_status_update(self):
if not self.has_value_changed("status"):
return
if not self.created_through_portal:
if self.status == "Unverified":
frappe.throw(_("Appointments created manually cannot have 'Unverified' status."))
return
if self.status == "Unverified" and self.email_verified:
frappe.throw(_("A verified appointment cannot be moved back to 'Unverified' status."))
if self.status == "Open" and not self.email_verified:
frappe.throw(
_("An appointment booked through the portal can only be opened via email verification.")
)
def validate_backdated_booking(self):
if get_datetime(self.scheduled_time) < now_datetime():
frappe.throw(_("Appointment cannot be scheduled for a past time."))
def validate_advanced_booking(self):
advance_booking_days = cint(get_booking_settings().advance_booking_days)
if advance_booking_days and date_diff(self.scheduled_time, now_datetime()) > advance_booking_days:
frappe.throw(
_("Appointment can only be scheduled up to {0} day(s) in advance.").format(
advance_booking_days
)
)
def validate_holiday(self):
holiday_list = get_booking_settings().holiday_list
if not holiday_list:
frappe.throw(_("Please add a valid Holiday List on Appointment Booking Settings."))
if is_holiday(holiday_list, getdate(self.scheduled_time)):
frappe.throw(_("Appointment cannot be scheduled on a holiday."))
def validate_slot_timing(self):
settings = get_booking_settings()
if not settings.availability_of_slots:
frappe.throw(_("No availability of slots are found. Please add on Appointment Booking Settings."))
scheduled_time = get_datetime(self.scheduled_time)
day_of_week = WEEKDAYS[scheduled_time.weekday()]
slot_start = timedelta(
hours=scheduled_time.hour, minutes=scheduled_time.minute, seconds=scheduled_time.second
)
if customer_list:
return customer_list[0].name
return None
slot_end = slot_start + timedelta(minutes=cint(settings.appointment_duration))
for slot in settings.availability_of_slots:
if slot.day_of_week == day_of_week and slot.from_time <= slot_start and slot_end <= slot.to_time:
return
frappe.throw(_("Appointment must be scheduled within the available slot timings."))
def validate_available_time_slot(self):
settings = get_booking_settings()
if not cint(settings.number_of_agents):
return
# the locking read serializes concurrent bookings for the same window,
# so two simultaneous requests cannot both pass the capacity check
booked = count_overlapping_appointments(
self.scheduled_time,
cint(settings.appointment_duration),
exclude_appointment=self.name,
for_update=True,
)
if booked >= cint(settings.number_of_agents):
frappe.throw(_("Time slot is not available"))
def before_insert(self):
number_of_appointments_in_same_slot = frappe.db.count(
"Appointment", filters={"scheduled_time": self.scheduled_time}
)
number_of_agents = frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents")
if number_of_agents != 0:
if number_of_appointments_in_same_slot >= number_of_agents:
frappe.throw(_("Time slot is not available"))
# Link lead
if not self.party:
lead = self.find_lead_by_email()
customer = self.find_customer_by_email()
if customer:
self.appointment_with = "Customer"
self.party = customer
else:
self.appointment_with = "Lead"
self.party = lead
# Set status to "Unverified" for new Appointments.
if self.created_through_portal:
self.status = "Unverified"
return
self.link_customer_lead()
def after_insert(self):
if self.party:
# Create Calendar event
if not self.created_through_portal and self.party:
self.auto_assign()
self.create_calendar_event()
else:
# Set status to unverified
self.db_set("status", "Unverified")
# Send email to confirm
self.send_confirmation_email()
return
# Send email to confirm
self.send_confirmation_email()
def on_update(self):
# capture transitions before nested saves during materialization
# refresh the before-save snapshot
status_changed = self.has_value_changed("status")
email_just_verified = bool(
self.created_through_portal and self.email_verified
) and self.has_value_changed("email_verified")
self.link_auto_assign_and_create_calendar_event()
if email_just_verified:
self.send_appointment_confirmed_email()
if status_changed:
self.update_event_and_assignments_status()
def on_trash(self):
# the Event only references the party, not the appointment,
# so it must be cleaned up explicitly
if not self.calendar_event:
return
event = self.calendar_event
self.db_set("calendar_event", None, update_modified=False)
frappe.delete_doc("Event", event, ignore_permissions=True)
def send_confirmation_email(self):
verify_url = self._get_verify_url()
template = "confirm_appointment"
args = {
"link": verify_url,
"site_url": frappe.utils.get_url(),
"full_name": self.customer_name,
}
self.send_email_to_customer(
template="confirm_appointment",
subject=_("Appointment Confirmation"),
args={"link": self._get_verify_url(), "expiry_minutes": get_verification_link_expiry()},
)
frappe.msgprint(_("Please check your email to confirm the appointment."))
def send_appointment_confirmed_email(self):
self.send_email_to_customer(
template="appointment_confirmed",
subject=_("Appointment Confirmed"),
args={"scheduled_time": frappe.utils.format_datetime(self.scheduled_time)},
reference_doctype="Appointment",
reference_name=self.name,
)
def send_email_to_customer(self, template, subject, args, **kwargs):
frappe.sendmail(
recipients=[self.customer_email],
template=template,
args=args,
subject=_("Appointment Confirmation"),
args={"full_name": self.customer_name, "site_url": frappe.utils.get_url(), **args},
subject=subject,
**kwargs,
)
if frappe.session.user == "Guest":
frappe.msgprint(_("Please check your email to confirm the appointment"))
else:
frappe.msgprint(
_("Appointment was created. But no lead was found. Please check the email to confirm")
)
def on_change(self):
# Sync Calendar
if not self.calendar_event:
def link_auto_assign_and_create_calendar_event(self):
if self.is_new() or (self.created_through_portal and not self.email_verified):
return
if not self.calendar_event:
# first materialization: link the party, assign an agent, create the event
self.link_customer_lead()
self.auto_assign()
self.create_calendar_event()
self.sync_calendar_event()
def sync_calendar_event(self):
if not self.calendar_event or not self.has_value_changed("scheduled_time"):
return
cal_event = frappe.get_doc("Event", self.calendar_event)
cal_event.starts_on = self.scheduled_time
cal_event.save(ignore_permissions=True)
def set_verified(self, email):
if email != self.customer_email:
frappe.throw(_("Email verification failed."))
# Create new lead
def update_event_and_assignments_status(self):
"""Close or reopen the calendar event and assignments along with the appointment."""
if self.status == "Unverified":
return
is_closed = self.status == "Closed"
new_status = "Closed" if is_closed else "Open"
if self.calendar_event:
frappe.db.set_value("Event", self.calendar_event, "status", new_status)
# only move ToDos between Open and Closed - never touch Cancelled ones
todo_filters = {
"reference_type": "Appointment",
"reference_name": self.name,
"status": "Open" if is_closed else "Closed",
}
frappe.db.set_value("ToDo", todo_filters, "status", new_status)
def link_customer_lead(self):
if not self.party:
customer = self.find_party_by_email("Customer")
self.appointment_with = "Customer" if customer else "Lead"
self.party = customer or self.find_party_by_email("Lead")
self.create_lead_and_link()
# Remove unverified status
self.status = "Open"
# Create calender event
self.auto_assign()
self.create_calendar_event()
self.save(ignore_permissions=True)
if not frappe.in_test:
frappe.db.commit()
def find_party_by_email(self, doctype):
party = frappe.get_all(doctype, filters={"email_id": self.customer_email}, limit=1, pluck="name")
return party[0] if party else None
def create_lead_and_link(self):
# Return if already linked
@@ -140,86 +269,39 @@ class Appointment(Document):
if self.customer_details:
lead.append(
"notes",
{
"note": self.customer_details,
"added_by": frappe.session.user,
"added_on": now(),
},
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
)
lead.insert(ignore_permissions=True)
# Link lead
self.party = lead.name
self.party = lead.insert(ignore_permissions=True).name
def auto_assign(self):
existing_assignee = self.get_assignee_from_latest_opportunity()
if existing_assignee:
# If the latest opportunity is assigned to someone
# Assign the appointment to the same
self.assign_agent(existing_assignee)
return
if self._assign:
return
available_agents = _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time))
for agent in available_agents:
if _check_agent_availability(agent, self.scheduled_time):
self.assign_agent(agent[0])
break
if existing_assignee := self.get_assignee_from_latest_opportunity():
# assign to whoever handles the party's latest opportunity
self.assign_agent(existing_assignee)
return
busy_agents = get_busy_agents(self.scheduled_time)
for agent in _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time)):
if agent not in busy_agents:
self.assign_agent(agent)
break
def get_assignee_from_latest_opportunity(self):
if not self.party:
if not self.party or not frappe.db.exists("Lead", self.party):
return None
if not frappe.db.exists("Lead", self.party):
return None
opporutnities = frappe.get_list(
opportunities = frappe.get_all(
"Opportunity",
filters={
"party_name": self.party,
},
ignore_permissions=True,
filters={"party_name": self.party},
fields=["_assign"],
order_by="creation desc",
limit=1,
)
if not opporutnities:
return None
latest_opportunity = frappe.get_doc("Opportunity", opporutnities[0].name)
assignee = latest_opportunity._assign
if not assignee:
return None
assignee = frappe.parse_json(assignee)[0]
return assignee
def create_calendar_event(self):
if self.calendar_event:
return
appointment_event = frappe.get_doc(
{
"doctype": "Event",
"subject": " ".join(["Appointment with", self.customer_name]),
"starts_on": self.scheduled_time,
"status": "Open",
"type": "Public",
"send_reminder": frappe.db.get_single_value(
"Appointment Booking Settings", "email_reminders"
),
"event_participants": [
dict(reference_doctype=self.appointment_with, reference_docname=self.party)
],
}
)
employee = _get_employee_from_user(self._assign)
if employee:
appointment_event.append(
"event_participants", dict(reference_doctype="Employee", reference_docname=employee.name)
)
appointment_event.insert(ignore_permissions=True)
self.calendar_event = appointment_event.name
self.save(ignore_permissions=True)
def _get_verify_url(self):
verify_route = "/book_appointment/verify"
params = {"email": self.customer_email, "appointment": self.name}
return get_url(verify_route + "?" + get_signed_params(params))
assignees = opportunities and frappe.parse_json(opportunities[0]._assign or "[]")
return assignees[0] if assignees else None
def assign_agent(self, agent):
if not frappe.has_permission(doc=self, user=agent):
@@ -227,45 +309,157 @@ class Appointment(Document):
add_assignment({"doctype": self.doctype, "name": self.name, "assign_to": [agent]})
def create_calendar_event(self):
if self.calendar_event:
return
event = frappe.get_doc(
{
"doctype": "Event",
"subject": f"Appointment with {self.customer_name}",
"starts_on": self.scheduled_time,
"status": "Open",
"type": "Public",
"send_reminder": cint(get_booking_settings().email_reminders),
"event_participants": self.get_event_participants(),
}
).insert(ignore_permissions=True)
self.calendar_event = event.name
self.save(ignore_permissions=True)
def get_event_participants(self):
participants = [dict(reference_doctype=self.appointment_with, reference_docname=self.party)]
if employee := _get_employee_from_user(self._assign):
participants.append(dict(reference_doctype="Employee", reference_docname=employee.name))
return participants
def _get_verify_url(self):
key = self.generate_verification_key()
return get_url("/book_appointment/verify?" + urlencode({"key": key}))
def generate_verification_key(self):
# store only the hash; the raw key lives solely in the emailed link
key = frappe.generate_hash()
self.db_set("verification_token", sha256_hash(key), update_modified=False)
return key
def get_booking_settings():
return frappe.get_cached_doc("Appointment Booking Settings")
def is_appointment_scheduling_enabled():
return bool(cint(get_booking_settings().enable_scheduling))
def get_verification_link_expiry():
"""Verification link expiry window in minutes."""
return cint(get_booking_settings().verification_link_expiry_duration)
def count_overlapping_appointments(
scheduled_time, appointment_duration, exclude_appointment=None, for_update=False
):
"""Count non-Closed appointments whose duration window overlaps `scheduled_time`.
With `for_update`, the range stays locked until commit, serializing concurrent bookings."""
# select the rows (not COUNT) so `for_update` stays valid: PostgreSQL
# rejects `FOR UPDATE` combined with an aggregate function
appointment = frappe.qb.DocType("Appointment")
query = (
frappe.qb.from_(appointment)
.select(appointment.name)
.where(appointment.scheduled_time > add_to_date(scheduled_time, minutes=-appointment_duration))
.where(appointment.scheduled_time < add_to_date(scheduled_time, minutes=appointment_duration))
.where(appointment.status != "Closed")
)
if exclude_appointment:
query = query.where(appointment.name != exclude_appointment)
if for_update:
query = query.for_update()
return len(query.run())
def handle_expired_unverified_appointments():
"""Close or delete Unverified appointments whose verification link has expired."""
expiry = get_verification_link_expiry()
if not expiry:
return
cutoff = add_to_date(now_datetime(), minutes=-expiry)
filters = {"status": "Unverified", "creation": ("<", cutoff)}
action = get_booking_settings().action_for_expired_unverified_appointments or "Mark as Closed"
if action == "Mark as Closed":
frappe.db.set_value("Appointment", filters, "status", "Closed")
elif action == "Delete Permanently":
for name in frappe.get_all("Appointment", filters=filters, pluck="name"):
frappe.delete_doc("Appointment", name, ignore_permissions=True)
def _get_agents_sorted_by_asc_workload(date):
appointments = frappe.get_all("Appointment", fields="*")
agent_list = _get_agent_list_as_strings()
if not appointments:
return agent_list
appointment_counter = Counter(agent_list)
for appointment in appointments:
assign_data = appointment._assign
if isinstance(assign_data, str):
assign_data = assign_data.strip()
if not assign_data:
continue
assigned_to = frappe.parse_json(assign_data)
if assigned_to and (assigned_to[0] in agent_list) and getdate(appointment.scheduled_time) == date:
appointment_counter[assigned_to[0]] += 1
sorted_agent_list = appointment_counter.most_common()
sorted_agent_list.reverse()
return sorted_agent_list
# count only the given day's assignments; scheduled_time is indexed so the
# date range is resolved in SQL instead of scanning every appointment ever
workload = Counter(agent.user for agent in get_booking_settings().agent_list)
assigns = frappe.get_all(
"Appointment",
filters=[
["_assign", "is", "set"],
["scheduled_time", ">=", getdate(date)],
["scheduled_time", "<", add_to_date(getdate(date), days=1)],
],
pluck="_assign",
)
for assign in assigns:
assignees = frappe.parse_json((assign or "").strip() or "[]")
if assignees and assignees[0] in workload:
workload[assignees[0]] += 1
return [agent for agent, _workload in reversed(workload.most_common())]
def _get_agent_list_as_strings():
agent_list_as_strings = []
agent_list = frappe.get_doc("Appointment Booking Settings").agent_list
for agent in agent_list:
agent_list_as_strings.append(agent.user)
return agent_list_as_strings
def get_busy_agents(scheduled_time):
"""Agents already assigned to a non-Closed appointment overlapping `scheduled_time`."""
duration = _get_appointment_duration()
assigns = frappe.get_all(
"Appointment",
filters=[
["scheduled_time", ">", add_to_date(scheduled_time, minutes=-duration)],
["scheduled_time", "<", add_to_date(scheduled_time, minutes=duration)],
["status", "!=", "Closed"],
],
pluck="_assign",
)
return {assignee for assign in assigns for assignee in frappe.parse_json(assign or "[]")}
def _check_agent_availability(agent_email, scheduled_time):
appointemnts_at_scheduled_time = frappe.get_all("Appointment", filters={"scheduled_time": scheduled_time})
for appointment in appointemnts_at_scheduled_time:
if appointment._assign == agent_email:
return False
return True
return agent_email not in get_busy_agents(scheduled_time)
def get_booked_slot_times(from_time, to_time):
"""scheduled_times of non-Closed appointments within (from_time, to_time), for slot availability."""
return frappe.get_all(
"Appointment",
filters=[
["scheduled_time", ">", from_time],
["scheduled_time", "<", to_time],
["status", "!=", "Closed"],
],
pluck="scheduled_time",
)
def _get_appointment_duration():
return cint(get_booking_settings().appointment_duration)
def _get_employee_from_user(user):
employee_docname = frappe.db.get_value("Employee", {"user_id": user})
if employee_docname:
return frappe.get_doc("Employee", employee_docname)
return None
return frappe.get_doc("Employee", employee_docname) if employee_docname else None

View File

@@ -1,37 +1,167 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import datetime
import unittest
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
import frappe
from frappe.utils import add_to_date, getdate, now_datetime, set_request
from frappe.utils.data import sha256_hash
from erpnext.crm.doctype.appointment.appointment import (
Appointment,
_check_agent_availability,
handle_expired_unverified_appointments,
)
from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list
from erpnext.tests.utils import ERPNextTestSuite
from erpnext.www.book_appointment.index import create_appointment, get_appointment_slots
from erpnext.www.book_appointment.verify import index as verify_index
LEAD_EMAIL = "test_appointment_lead@example.com"
VERIFICATION_EXPIRY_MINUTES = 30
ALL_WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def create_test_appointment():
test_appointment = frappe.get_doc(
{
"doctype": "Appointment",
"status": "Open",
"customer_name": "Test Lead",
"customer_phone_number": "666",
"customer_skype": "test",
"customer_email": LEAD_EMAIL,
"scheduled_time": datetime.datetime.now(),
"customer_details": "Hello, Friend!",
}
)
def create_test_appointment(**kwargs):
args = {
"doctype": "Appointment",
"status": "Open",
"customer_name": "Test Lead",
"customer_phone_number": "666",
"customer_skype": "test",
"customer_email": LEAD_EMAIL,
"scheduled_time": add_to_date(now_datetime(), hours=2),
"customer_details": "Hello, Friend!",
}
args.update(kwargs)
test_appointment = frappe.get_doc(args)
test_appointment.insert()
return test_appointment
def create_lead(email, name="Existing Lead"):
frappe.db.delete("Lead", {"email_id": email})
return frappe.get_doc({"doctype": "Lead", "lead_name": name, "email_id": email}).insert(
ignore_permissions=True
)
def set_booking_setting(field, value):
frappe.db.set_single_value("Appointment Booking Settings", field, value)
def slot_on(days_from_now, hour, minute=0):
day = datetime.date.today() + datetime.timedelta(days=days_from_now)
return datetime.datetime.combine(day, datetime.time(hour, minute))
def backdate_creation(appointment_name, minutes):
frappe.db.set_value(
"Appointment",
appointment_name,
"creation",
add_to_date(now_datetime(), minutes=-minutes),
update_modified=False,
)
def get_status(appointment_name):
return frappe.db.get_value("Appointment", appointment_name, "status")
def get_assignees(appointment_name):
return frappe.parse_json(frappe.db.get_value("Appointment", appointment_name, "_assign") or "[]")
def get_todo_statuses(appointment_name):
return frappe.get_all(
"ToDo",
filters={"reference_type": "Appointment", "reference_name": appointment_name},
pluck="status",
)
def parse_verify_url(verify_url):
parsed = urlparse(verify_url)
return parsed, {key: value[0] for key, value in parse_qs(parsed.query).items()}
class TestAppointment(ERPNextTestSuite):
def setUp(self):
set_booking_setting("verification_link_expiry_duration", VERIFICATION_EXPIRY_MINUTES)
frappe.db.delete("Lead", {"email_id": LEAD_EMAIL})
self.test_appointment = create_test_appointment()
self.test_appointment.set_verified(self.test_appointment.customer_email)
def _configure_booking_settings(self, holiday_dates=None, agents=None):
holiday_list = make_holiday_list(
"_Test Appointment Holiday List",
from_date=getdate(),
to_date=add_to_date(getdate(), days=60),
holiday_dates=holiday_dates or [],
)
settings = frappe.get_doc("Appointment Booking Settings")
settings.enable_scheduling = 1
settings.enable_appointment_portal = 1
settings.appointment_duration = 30
settings.advance_booking_days = 30
settings.verification_link_expiry_duration = VERIFICATION_EXPIRY_MINUTES
settings.holiday_list = holiday_list.name
settings.set("agent_list", [])
for agent in agents or ["Administrator"]:
settings.append("agent_list", {"user": agent})
settings.set("availability_of_slots", [])
for day in ALL_WEEKDAYS:
settings.append(
"availability_of_slots", {"day_of_week": day, "from_time": "09:00:00", "to_time": "17:00:00"}
)
settings.save()
def _create_portal_appointment(self, email, days_from_now=7, time="10:00:00"):
"""Book as Guest. The verification email is mocked and kept on
``self._verification_email_mock`` for assertions."""
if not getattr(self, "_booking_settings_configured", False):
self._configure_booking_settings()
self._booking_settings_configured = True
with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send:
appointment = create_appointment(
date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)),
time=time,
tz="UTC",
contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""},
)
self._verification_email_mock = mock_send
return appointment
def _request_verification(self, appointment, verify_url=None):
"""Simulate the GET request made by clicking the emailed verification link.
The confirmation email sent on successful verification is mocked and kept
on ``self._confirmed_email_mock`` for assertions.
"""
parsed, params = parse_verify_url(verify_url or appointment._get_verify_url())
old_request = getattr(frappe.local, "request", None)
old_form_dict = frappe.local.form_dict
old_user = frappe.session.user
try:
# the real link is clicked by an anonymous visitor; set_user resets
# form_dict, so switch the user before populating the request
frappe.set_user("Guest")
set_request(method="GET", path=f"{parsed.path}?{parsed.query}")
frappe.local.form_dict = frappe._dict(params)
context = frappe._dict()
with patch.object(Appointment, "send_appointment_confirmed_email") as mock_confirmed:
verify_index.get_context(context)
self._confirmed_email_mock = mock_confirmed
return context
finally:
frappe.set_user(old_user)
frappe.local.request = old_request
frappe.local.form_dict = old_form_dict
frappe.local.flags.commit = False
def test_calendar_event_created(self):
cal_event = frappe.get_doc("Event", self.test_appointment.calendar_event)
@@ -39,3 +169,371 @@ class TestAppointment(ERPNextTestSuite):
def test_lead_linked(self):
self.assertTrue(self.test_appointment.party)
def test_desk_created_appointment_skips_email_verification(self):
"""Appointments created from the desk (created_through_portal unset) must be
linked and confirmed immediately - no verification email should be sent."""
with patch.object(Appointment, "send_confirmation_email") as mock_send:
appointment = create_test_appointment(customer_email="another_desk_lead@example.com")
mock_send.assert_not_called()
self.assertEqual(appointment.status, "Open")
self.assertTrue(appointment.party)
frappe.db.delete("Lead", {"email_id": "another_desk_lead@example.com"})
def test_portal_booking_stays_unverified_for_existing_lead(self):
"""A portal booking whose email matches an existing Lead/Customer must NOT
be auto-linked - it must stay Unverified until the email is confirmed."""
create_lead("existing_lead@example.com")
appointment = self._create_portal_appointment("existing_lead@example.com", days_from_now=5)
self._verification_email_mock.assert_called_once()
self.assertTrue(appointment.created_through_portal)
self.assertEqual(appointment.status, "Unverified")
self.assertFalse(appointment.email_verified)
self.assertFalse(appointment.party)
def test_verify_url_uses_opaque_token(self):
appointment = self._create_portal_appointment("portal_visitor@example.com")
parsed, params = parse_verify_url(appointment._get_verify_url())
# the link carries only an opaque key - no email, name or signed params
self.assertEqual(set(params), {"key"})
self.assertNotIn("email", parsed.query)
# only the hash of that key is stored on the appointment
stored = frappe.db.get_value("Appointment", appointment.name, "verification_token")
self.assertEqual(stored, sha256_hash(params["key"]))
def test_email_verification_within_expiry_window(self):
# Link used within the validity window - verification succeeds and the
# appointment gets linked, assigned and added to the calendar
on_time = self._create_portal_appointment("portal_visitor_on_time@example.com")
context = self._request_verification(on_time)
self.assertTrue(context.success)
self._confirmed_email_mock.assert_called_once()
on_time.reload()
self.assertEqual(on_time.status, "Open")
self.assertTrue(on_time.email_verified)
self.assertTrue(on_time.party)
self.assertTrue(on_time.calendar_event)
# Link used after the validity window - verification fails
late = self._create_portal_appointment("portal_visitor_late@example.com", days_from_now=10)
after_expiry = add_to_date(now_datetime(), minutes=VERIFICATION_EXPIRY_MINUTES + 1)
with patch.object(verify_index, "now_datetime", return_value=after_expiry):
context = self._request_verification(late)
self.assertFalse(context.success)
self._confirmed_email_mock.assert_not_called()
late.reload()
self.assertEqual(late.status, "Unverified")
self.assertFalse(late.email_verified)
self.assertFalse(late.party)
def test_verification_link_reused_after_success(self):
appointment = self._create_portal_appointment("portal_visitor_twice@example.com")
verify_url = appointment._get_verify_url()
context = self._request_verification(appointment, verify_url=verify_url)
self.assertTrue(context.success)
self._confirmed_email_mock.assert_called_once()
# re-clicking the link is idempotent and does not send another email
context = self._request_verification(appointment, verify_url=verify_url)
self.assertTrue(context.success)
self.assertIn("already verified", context.message)
self._confirmed_email_mock.assert_not_called()
def test_verification_link_for_deleted_appointment(self):
"""A verification link can outlive its appointment - clicking it must
render a friendly message, not crash."""
appointment = self._create_portal_appointment("portal_visitor_gone@example.com")
verify_url = appointment._get_verify_url()
frappe.delete_doc("Appointment", appointment.name, ignore_permissions=True)
context = self._request_verification(appointment, verify_url=verify_url)
self.assertFalse(context.success)
self.assertIn("book the appointment again", context.message)
def test_reschedule_syncs_calendar_event(self):
new_time = add_to_date(self.test_appointment.scheduled_time, hours=1)
self.test_appointment.scheduled_time = new_time
self.test_appointment.save()
starts_on = frappe.db.get_value("Event", self.test_appointment.calendar_event, "starts_on")
self.assertEqual(starts_on, new_time)
def test_portal_endpoint_disabled(self):
self._configure_booking_settings()
set_booking_setting("enable_appointment_portal", 0)
with self.set_user("Guest"), self.assertRaises(frappe.Redirect):
create_appointment(
date=str(datetime.date.today() + datetime.timedelta(days=3)),
time="10:00:00",
tz="UTC",
contact={
"name": "Blocked",
"email": "blocked@example.com",
"number": "1",
"skype": "",
"notes": "",
},
)
def test_booked_slot_unavailable_on_portal(self):
from frappe.utils.data import get_system_timezone
self._configure_booking_settings()
tz = get_system_timezone()
day = datetime.date.today() + datetime.timedelta(days=2)
def get_availability():
with self.set_user("Guest"):
slots = get_appointment_slots(str(day), tz)
return {slot["time"].strftime("%H:%M"): slot["availability"] for slot in slots}
booked = create_test_appointment(
customer_email="slot_taken@example.com", scheduled_time=slot_on(2, 10)
)
availability = get_availability()
self.assertFalse(availability["10:00"])
self.assertTrue(availability["13:00"])
# closing the appointment frees its slot on the portal
booked.status = "Closed"
booked.save()
self.assertTrue(get_availability()["10:00"])
# an off-grid desk appointment blocks every portal slot it overlaps
create_test_appointment(customer_email="off_grid@example.com", scheduled_time=slot_on(2, 13, 15))
availability = get_availability()
self.assertFalse(availability["13:00"])
self.assertFalse(availability["13:30"])
self.assertTrue(availability["14:00"])
def test_expired_unverified_appointments_are_closed(self):
stale = self._create_portal_appointment("portal_visitor_stale@example.com", days_from_now=8)
fresh = self._create_portal_appointment("portal_visitor_fresh@example.com", days_from_now=9)
verify_url = stale._get_verify_url()
backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15)
set_booking_setting("action_for_expired_unverified_appointments", "Mark as Closed")
handle_expired_unverified_appointments()
self.assertEqual(get_status(stale.name), "Closed")
self.assertEqual(get_status(fresh.name), "Unverified")
# Open appointments are never touched, regardless of age
self.assertEqual(get_status(self.test_appointment.name), "Open")
# clicking the link of a closed appointment renders a friendly message
context = self._request_verification(stale, verify_url=verify_url)
self.assertFalse(context.success)
self.assertIn("closed", context.message)
def test_expired_unverified_appointments_are_deleted(self):
stale = self._create_portal_appointment("portal_visitor_purged@example.com", days_from_now=8)
fresh = self._create_portal_appointment("portal_visitor_kept@example.com", days_from_now=9)
backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15)
set_booking_setting("action_for_expired_unverified_appointments", "Delete Permanently")
handle_expired_unverified_appointments()
self.assertFalse(frappe.db.exists("Appointment", stale.name))
self.assertTrue(frappe.db.exists("Appointment", fresh.name))
self.assertTrue(frappe.db.exists("Appointment", self.test_appointment.name))
def test_cleanup_skipped_when_expiry_not_configured(self):
appointment = self._create_portal_appointment("portal_visitor_no_expiry@example.com")
backdate_creation(appointment.name, 5)
set_booking_setting("verification_link_expiry_duration", 0)
handle_expired_unverified_appointments()
self.assertEqual(get_status(appointment.name), "Unverified")
def test_status_transition_rules(self):
# desk appointments can never be Unverified
with self.assertRaises(frappe.ValidationError):
create_test_appointment(customer_email="desk_unverified@example.com", status="Unverified")
# portal appointments cannot be opened manually before verification
unverified = self._create_portal_appointment("manual_open@example.com")
unverified.status = "Open"
with self.assertRaises(frappe.ValidationError):
unverified.save(ignore_permissions=True)
# verified appointments cannot be reverted to Unverified
verified = self._create_portal_appointment("revert_unverified@example.com", days_from_now=8)
self._request_verification(verified)
verified.reload()
verified.status = "Unverified"
with self.assertRaises(frappe.ValidationError):
verified.save(ignore_permissions=True)
# both desk and verified portal appointments can be closed and reopened
for appointment in (self.test_appointment, verified):
appointment.reload()
appointment.status = "Closed"
appointment.save(ignore_permissions=True)
appointment.status = "Open"
appointment.save(ignore_permissions=True)
self.assertEqual(appointment.status, "Open")
def test_agent_auto_assignment(self):
agent_email = "appointment_agent@example.com"
if not frappe.db.exists("User", agent_email):
frappe.get_doc(
{"doctype": "User", "email": agent_email, "first_name": "Appointment Agent"}
).insert(ignore_permissions=True)
self._configure_booking_settings(agents=["Administrator", agent_email])
first = create_test_appointment(
customer_email="assigned_one@example.com", scheduled_time=slot_on(2, 11)
)
second = create_test_appointment(
customer_email="assigned_two@example.com", scheduled_time=slot_on(2, 11)
)
# both appointments in the same slot get an agent, and never the same one
self.assertTrue(get_assignees(first.name))
self.assertTrue(get_assignees(second.name))
self.assertNotEqual(get_assignees(first.name), get_assignees(second.name))
# closing an assigned appointment closes its ToDo without re-assigning
first.reload()
first.status = "Closed"
first.save()
self.assertTrue(get_todo_statuses(first.name))
self.assertTrue(all(status == "Closed" for status in get_todo_statuses(first.name)))
# reopening brings the ToDos back
first.status = "Open"
first.save()
self.assertTrue(all(status == "Open" for status in get_todo_statuses(first.name)))
def test_agent_busy_for_the_whole_appointment_duration(self):
self._configure_booking_settings()
slot = slot_on(3, 11)
appointment = create_test_appointment(customer_email="busy_agent@example.com", scheduled_time=slot)
assignee = get_assignees(appointment.name)[0]
# busy anywhere inside the 30-minute appointment window, free right after it
self.assertFalse(_check_agent_availability(assignee, slot))
self.assertFalse(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=15)))
self.assertTrue(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=30)))
def test_closed_appointment_closes_calendar_event(self):
self.test_appointment.status = "Closed"
self.test_appointment.save()
event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status")
self.assertEqual(event_status, "Closed")
# reopening the appointment reopens the calendar event
self.test_appointment.status = "Open"
self.test_appointment.save()
event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status")
self.assertEqual(event_status, "Open")
def test_deleting_appointment_deletes_calendar_event(self):
event = self.test_appointment.calendar_event
self.assertTrue(frappe.db.exists("Event", event))
frappe.delete_doc("Appointment", self.test_appointment.name)
self.assertFalse(frappe.db.exists("Event", event))
def test_backdated_appointment_is_rejected(self):
with self.assertRaises(frappe.ValidationError):
create_test_appointment(
customer_email="backdated@example.com",
scheduled_time=add_to_date(now_datetime(), hours=-1),
)
def test_booking_beyond_advance_window_is_rejected(self):
self._configure_booking_settings()
set_booking_setting("advance_booking_days", 7)
# within the advance booking window - allowed
within = create_test_appointment(
customer_email="advance_within@example.com", scheduled_time=slot_on(5, 10)
)
self.assertTrue(frappe.db.exists("Appointment", within.name))
# beyond the advance booking window - rejected
with self.assertRaises(frappe.ValidationError):
create_test_appointment(
customer_email="advance_beyond@example.com", scheduled_time=slot_on(8, 10)
)
def test_appointment_on_holiday_is_rejected(self):
holiday = add_to_date(getdate(), days=3)
self._configure_booking_settings(
holiday_dates=[{"holiday_date": holiday, "description": "Test Holiday"}]
)
with self.assertRaises(frappe.ValidationError):
create_test_appointment(customer_email="on_holiday@example.com", scheduled_time=slot_on(3, 10))
# the day after the holiday is bookable
after_holiday = create_test_appointment(
customer_email="after_holiday@example.com", scheduled_time=slot_on(4, 10)
)
self.assertTrue(frappe.db.exists("Appointment", after_holiday.name))
def test_appointment_outside_slot_timing_is_rejected(self):
self._configure_booking_settings()
# before the slot opens
with self.assertRaises(frappe.ValidationError):
create_test_appointment(customer_email="before_opening@example.com", scheduled_time=slot_on(2, 8))
# starts within the slot but would end after it closes
with self.assertRaises(frappe.ValidationError):
create_test_appointment(
customer_email="past_closing@example.com", scheduled_time=slot_on(2, 16, 45)
)
# within the slot timings
within = create_test_appointment(
customer_email="within_slot@example.com", scheduled_time=slot_on(2, 10)
)
self.assertTrue(frappe.db.exists("Appointment", within.name))
def test_overlapping_time_slot_capacity(self):
set_booking_setting("number_of_agents", 1)
set_booking_setting("appointment_duration", 30)
slot = slot_on(1, 10)
first = create_test_appointment(customer_email="slot_first@example.com", scheduled_time=slot)
# a booking starting inside the first appointment's duration is rejected
with self.assertRaises(frappe.ValidationError):
create_test_appointment(
customer_email="slot_overlap@example.com",
scheduled_time=slot + datetime.timedelta(minutes=15),
)
# rescheduling must not count the appointment's own booked slot
first.scheduled_time = slot + datetime.timedelta(minutes=10)
first.save()
# a booking starting exactly when the rescheduled one ends is allowed
adjacent = create_test_appointment(
customer_email="slot_adjacent@example.com",
scheduled_time=slot + datetime.timedelta(minutes=40),
)
self.assertTrue(frappe.db.exists("Appointment", adjacent.name))
# a closed (cancelled) appointment frees its slot
first.status = "Closed"
first.save()
after_cancellation = create_test_appointment(
customer_email="after_cancellation@example.com", scheduled_time=slot
)
self.assertTrue(frappe.db.exists("Appointment", after_cancellation.name))

View File

@@ -1,48 +1,56 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2019-08-27 10:56:48.309824",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"enable_scheduling",
"agent_detail_section",
"availability_of_slots",
"number_of_agents",
"agent_list",
"holiday_list",
"appointment_details_section",
"appointment_duration",
"email_reminders",
"column_break_ehiq",
"agent_list",
"number_of_agents",
"agent_detail_section",
"enable_scheduling",
"availability_of_slots",
"section_break_bkln",
"column_break_alwa",
"advance_booking_days",
"column_break_bspp",
"holiday_list",
"success_details",
"success_redirect_url"
"enable_appointment_portal",
"verification_link_expiry_duration",
"column_break_fovk",
"success_redirect_url",
"action_for_expired_unverified_appointments"
],
"fields": [
{
"depends_on": "eval:doc.enable_scheduling === 1;",
"fieldname": "availability_of_slots",
"fieldtype": "Table",
"label": "Availability Of Slots",
"options": "Appointment Booking Slots",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
"options": "Appointment Booking Slots"
},
{
"default": "1",
"fieldname": "number_of_agents",
"fieldtype": "Int",
"hidden": 1,
"in_list_view": 1,
"label": "Number of Concurrent Appointments",
"read_only": 1,
"reqd": 1
"read_only": 1
},
{
"depends_on": "eval:doc.enable_scheduling === 1;",
"fieldname": "holiday_list",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Holiday List",
"options": "Holiday List",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
"options": "Holiday List"
},
{
"default": "60",
@@ -60,29 +68,31 @@
},
{
"default": "7",
"depends_on": "eval:doc.enable_scheduling === 1;",
"fieldname": "advance_booking_days",
"fieldtype": "Int",
"label": "Number of days appointments can be booked in advance",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;"
},
{
"fieldname": "agent_list",
"fieldtype": "Table MultiSelect",
"label": "Agents",
"options": "Assignment Rule User",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
"options": "Assignment Rule User"
},
{
"default": "0",
"fieldname": "enable_scheduling",
"fieldtype": "Check",
"label": "Enable Appointment Scheduling",
"reqd": 1
"mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;"
},
{
"fieldname": "agent_detail_section",
"fieldtype": "Section Break",
"label": "Agent Details"
"hide_border": 1,
"label": "Appointment Scheduling"
},
{
"fieldname": "appointment_details_section",
@@ -92,20 +102,68 @@
{
"fieldname": "success_details",
"fieldtype": "Section Break",
"label": "Success Settings"
"label": "Appointment Booking Portal Settings"
},
{
"description": "Leave blank for home.\nThis is relative to site URL, for example \"about\" will redirect to \"https://yoursitename.com/about\"",
"fieldname": "success_redirect_url",
"fieldtype": "Data",
"label": "Success Redirect URL"
"label": "Success Redirect URL",
"permlevel": 1
},
{
"default": "30",
"depends_on": "eval: doc.enable_scheduling === 1;",
"description": "In Minutes (min: 15 mins, max: 60 mins)",
"fieldname": "verification_link_expiry_duration",
"fieldtype": "Int",
"label": "Verification Link Expiry Duration",
"mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;",
"max_value": 60.0,
"min_value": 15.0,
"non_negative": 1,
"permlevel": 1
},
{
"fieldname": "column_break_ehiq",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "enable_appointment_portal",
"fieldtype": "Check",
"label": "Enable Appointment Booking Through Portal",
"permlevel": 1
},
{
"fieldname": "column_break_fovk",
"fieldtype": "Column Break"
},
{
"default": "Mark as Closed",
"fieldname": "action_for_expired_unverified_appointments",
"fieldtype": "Select",
"label": "Action for Expired Unverified Appointments",
"options": "Mark as Closed\nDelete Permanently",
"permlevel": 1
},
{
"fieldname": "section_break_bkln",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_alwa",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_bspp",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"hide_toolbar": 0,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:21.198138",
"modified": "2026-07-20 00:11:18.996384",
"modified_by": "Administrator",
"module": "CRM",
"name": "Appointment Booking Settings",
@@ -139,6 +197,15 @@
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"permlevel": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,

View File

@@ -3,11 +3,11 @@
import datetime
import typing
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate
class AppointmentBookingSettings(Document):
@@ -24,33 +24,43 @@ class AppointmentBookingSettings(Document):
AppointmentBookingSlots,
)
action_for_expired_unverified_appointments: DF.Literal["Mark as Closed", "Delete Permanently"]
advance_booking_days: DF.Int
agent_list: DF.TableMultiSelect[AssignmentRuleUser]
appointment_duration: DF.Int
availability_of_slots: DF.Table[AppointmentBookingSlots]
email_reminders: DF.Check
enable_appointment_portal: DF.Check
enable_scheduling: DF.Check
holiday_list: DF.Link
holiday_list: DF.Link | None
number_of_agents: DF.Int
success_redirect_url: DF.Data | None
verification_link_expiry_duration: DF.Int
# end: auto-generated types
agent_list: typing.ClassVar[list] = [] # Hack
min_date = "01/01/1970 "
format_string = "%d/%m/%Y %H:%M:%S"
def validate(self):
self.validate_availability_of_slots()
def save(self):
self.number_of_agents = len(self.agent_list)
super().save()
self.validate_appointment_scheduling()
self.validate_portal_booking()
def validate_appointment_scheduling(self):
if not self.enable_scheduling:
return
self.validate_availability_of_slots()
self.validate_holiday_list()
self.validate_advance_booking_days()
def validate_availability_of_slots(self):
if not self.availability_of_slots:
frappe.throw(
_("Please fill up the Availability of Slots table to enable Appointment Scheduling.")
)
format_string = "%Y-%m-%d %H:%M:%S"
for record in self.availability_of_slots:
from_time = datetime.datetime.strptime(self.min_date + record.from_time, self.format_string)
to_time = datetime.datetime.strptime(self.min_date + record.to_time, self.format_string)
to_time - from_time
from_time = datetime.datetime.strptime(f"1970-01-01 {record.from_time}", format_string)
to_time = datetime.datetime.strptime(f"1970-01-01 {record.to_time}", format_string)
self.validate_from_and_to_time(from_time, to_time, record)
self.duration_is_divisible(from_time, to_time)
@@ -65,3 +75,38 @@ class AppointmentBookingSettings(Document):
timedelta = to_time - from_time
if timedelta.total_seconds() % (self.appointment_duration * 60):
frappe.throw(_("The difference between from time and To Time must be a multiple of Appointment"))
def validate_holiday_list(self):
if not self.holiday_list:
frappe.throw(_("Please select a Holiday List to enable Appointment Scheduling."))
hl_from_date, hl_to_date = frappe.get_cached_value(
"Holiday List", self.holiday_list, ["from_date", "to_date"]
)
now = getdate()
if not (now >= hl_from_date and now <= hl_to_date):
frappe.throw(_("Holiday List - {0} is not valid for current date.").format(self.holiday_list))
def validate_advance_booking_days(self):
if not self.advance_booking_days:
frappe.throw(_("Advance Booking Days is mandatory for Appointment Scheduling."))
def validate_portal_booking(self):
if not self.enable_appointment_portal:
return
if not self.enable_scheduling:
frappe.throw(
_("Appointment Scheduling needs to be enabled for Appointment Booking through portal.")
)
self.validate_link_expiry_duration()
def validate_link_expiry_duration(self):
if (
not self.verification_link_expiry_duration
or self.verification_link_expiry_duration > 60
or self.verification_link_expiry_duration < 15
):
frappe.throw(_("'Verification Link Expiry Duration' must be between 15 to 60 minutes."))

View File

@@ -1,10 +1,125 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import unittest
import datetime
import frappe
from frappe.utils import add_to_date, getdate
from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list
from erpnext.tests.utils import ERPNextTestSuite
class TestAppointmentBookingSettings(ERPNextTestSuite):
pass
def assert_invalid(self, settings):
with self.assertRaises(frappe.ValidationError):
settings.save()
def make_settings(self, appointment_duration=30):
doc = frappe.new_doc("Appointment Booking Settings")
doc.appointment_duration = appointment_duration
return doc
def dt(self, hms):
# the controller parses times against a fixed epoch date
return datetime.datetime.strptime("1970-01-01 " + hms, "%Y-%m-%d %H:%M:%S")
def get_valid_scheduling_settings(self):
holiday_list = make_holiday_list(
"_Test Booking Settings Holiday List",
from_date=getdate(),
to_date=add_to_date(getdate(), days=30),
holiday_dates=[],
)
settings = frappe.get_doc("Appointment Booking Settings")
settings.enable_scheduling = 1
settings.appointment_duration = 30
settings.advance_booking_days = 7
settings.verification_link_expiry_duration = 30
settings.holiday_list = holiday_list.name
settings.set("agent_list", [])
settings.append("agent_list", {"user": "Administrator"})
settings.set("availability_of_slots", [])
settings.append(
"availability_of_slots",
{"day_of_week": "Monday", "from_time": "09:00:00", "to_time": "17:00:00"},
)
return settings
def test_from_time_must_precede_to_time(self):
doc = self.make_settings()
record = frappe._dict(day_of_week="Monday")
self.assertRaises(
frappe.ValidationError,
doc.validate_from_and_to_time,
self.dt("18:00:00"),
self.dt("09:00:00"),
record,
)
doc.validate_from_and_to_time(self.dt("09:00:00"), self.dt("18:00:00"), record) # valid order
def test_slot_length_must_be_a_multiple_of_the_duration(self):
doc = self.make_settings(appointment_duration=30)
# 60 minutes is two 30-minute appointments -> fine
doc.duration_is_divisible(self.dt("09:00:00"), self.dt("10:00:00"))
# 45 minutes leaves a partial appointment -> rejected
self.assertRaises(
frappe.ValidationError, doc.duration_is_divisible, self.dt("09:00:00"), self.dt("09:45:00")
)
def test_scheduling_requires_slots(self):
settings = self.get_valid_scheduling_settings()
settings.set("availability_of_slots", [])
self.assert_invalid(settings)
def test_validate_checks_every_slot(self):
settings = self.get_valid_scheduling_settings()
settings.append(
"availability_of_slots",
{"day_of_week": "Tuesday", "from_time": "09:00:00", "to_time": "09:45:00"},
)
self.assert_invalid(settings)
def test_scheduling_requires_holiday_list_covering_today(self):
settings = self.get_valid_scheduling_settings()
settings.holiday_list = None
self.assert_invalid(settings)
expired_list = make_holiday_list(
"_Test Booking Settings Expired Holiday List",
from_date=add_to_date(getdate(), days=-60),
to_date=add_to_date(getdate(), days=-30),
holiday_dates=[],
)
settings.holiday_list = expired_list.name
self.assert_invalid(settings)
def test_scheduling_requires_advance_booking_days(self):
settings = self.get_valid_scheduling_settings()
settings.advance_booking_days = 0
self.assert_invalid(settings)
def test_portal_requires_scheduling(self):
settings = frappe.get_doc("Appointment Booking Settings")
settings.enable_scheduling = 0
settings.enable_appointment_portal = 1
self.assert_invalid(settings)
def test_portal_expiry_duration_bounds(self):
settings = self.get_valid_scheduling_settings()
settings.enable_appointment_portal = 1
settings.verification_link_expiry_duration = 5
self.assert_invalid(settings)
def test_number_of_agents_derived_from_agent_list(self):
settings = self.get_valid_scheduling_settings()
settings.number_of_agents = 99
settings.save()
self.assertEqual(frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents"), 1)

View File

@@ -134,6 +134,7 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
self.validate_qty()
self.set_exchange_rate()
if not self.title:
@@ -144,6 +145,15 @@ class Opportunity(TransactionBase, CRMNote):
def on_update(self):
self.update_prospect()
def validate_qty(self):
for item in self.items:
if flt(item.qty) <= 0:
frappe.throw(
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
item.idx, item.item_code
)
)
def map_fields(self):
for field in self.meta.get_valid_columns():
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):
@@ -279,13 +289,17 @@ class Opportunity(TransactionBase, CRMNote):
self.save()
else:
frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
frappe.throw(_("Cannot declare as Lost because an active Quotation exists."))
def has_active_quotation(self):
if not self.get("items", []):
return frappe.get_all(
"Quotation",
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
{
"opportunity": self.name,
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
"docstatus": 1,
},
"name",
)
else:
@@ -294,14 +308,20 @@ class Opportunity(TransactionBase, CRMNote):
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status not in ('Lost', 'Closed')""",
and q.status not in ('Lost', 'Cancelled', 'Expired')""",
self.name,
)
def has_ordered_quotation(self):
if not self.get("items", []):
return frappe.get_all(
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
"Quotation",
{
"opportunity": self.name,
"status": ("in", ["Ordered", "Partially Ordered"]),
"docstatus": 1,
},
"name",
)
else:
return frappe.db.sql(
@@ -309,7 +329,7 @@ class Opportunity(TransactionBase, CRMNote):
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status = 'Ordered'""",
and q.status in ('Ordered', 'Partially Ordered')""",
self.name,
)

View File

@@ -433,8 +433,6 @@ scheduler_events = {
"cron": {
"0/15 * * * *": [
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
],
"0/30 * * * *": [
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.run_parallel_reposting",
],
# Hourly but offset by 30 minutes
@@ -449,6 +447,7 @@ scheduler_events = {
],
"hourly_long": [],
"hourly_maintenance": [
"erpnext.crm.doctype.appointment.appointment.handle_expired_unverified_appointments",
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
"erpnext.utilities.bulk_transaction.retry",
"erpnext.projects.doctype.project.project.collect_project_status",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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