Compare commits

..

66 Commits

Author SHA1 Message Date
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
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
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
101 changed files with 59063 additions and 43805 deletions

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.29.0"
def get_default_company(user=None):

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

@@ -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

@@ -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

@@ -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

@@ -784,6 +784,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"),
}
)

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

@@ -3156,8 +3156,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 +3164,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

@@ -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

@@ -1347,66 +1347,63 @@ 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,
)
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
if flt(reserved_qty, 6) <= 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, 6) >= flt(reserved_qty, 6):
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 +1415,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)))
@@ -2057,7 +2055,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 +2069,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")

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

@@ -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

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

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

View File

@@ -445,8 +445,6 @@ frappe.ui.form.on("Production Plan", {
frappe.throw(__("Select the Warehouse"));
}
frm.set_value("consider_minimum_order_qty", 0);
if (!frm.doc.ignore_existing_ordered_qty) {
frm.events.get_items_for_material_requests(frm);
} else {

View File

@@ -1823,7 +1823,13 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
if (ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses:
new_mr_items = []
for item in mr_items:
get_materials_from_other_locations(item, warehouses, new_mr_items, company)
get_materials_from_other_locations(
item,
warehouses,
new_mr_items,
company,
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
)
mr_items = new_mr_items
@@ -1845,7 +1851,9 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
return mr_items
def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
def get_materials_from_other_locations(
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False
):
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
@@ -1888,7 +1896,8 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
precision = frappe.get_precision("Material Request Plan Item", "quantity")
if flt(required_qty, precision) > 0:
required_qty = required_qty
if consider_minimum_order_qty:
required_qty = max(required_qty, flt(item.get("min_order_qty")))
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
required_qty = ceil(required_qty)

View File

@@ -2030,6 +2030,18 @@ class TestProductionPlan(ERPNextTestSuite):
for d in mr_items:
self.assertEqual(d.get("quantity"), 1000.0)
source_warehouse = create_warehouse("MOQ Source Warehouse", company="_Test Company")
make_stock_entry(item_code=rm_item, qty=7, rate=100, target=source_warehouse)
pln.ignore_existing_ordered_qty = 1
mr_items = get_items_for_material_requests(
pln.as_dict(), warehouses=[{"warehouse": source_warehouse}]
)
self.assertEqual(len(mr_items), 2)
items_by_type = {d.get("material_request_type"): d for d in mr_items}
self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0)
self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0)
def test_fg_item_quantity(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name

View File

@@ -28,6 +28,7 @@
"fieldtype": "Column Break"
},
{
"fetch_from": "material_request.transaction_date",
"fieldname": "material_request_date",
"fieldtype": "Date",
"in_list_view": 1,
@@ -41,13 +42,15 @@
],
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:20.526011",
"modified": "2026-07-17 22:06:35.428875",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Material Request",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -2983,7 +2983,7 @@ def get_work_order_operation_data(work_order, operation, workstation):
@frappe.whitelist()
def create_pick_list(source_name, target_doc=None, for_qty=None):
def create_pick_list(source_name: str, target_doc: str | dict | None = None, for_qty: float | None = None):
for_qty = for_qty or json.loads(target_doc).get("for_qty")
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
@@ -3013,6 +3013,7 @@ def create_pick_list(source_name, target_doc=None, for_qty=None):
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
"Work Order Item": {
"doctype": "Pick List Item",
"field_no_map": ["transferred_qty"],
"postprocess": update_item_quantity,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
},

View File

@@ -490,3 +490,4 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
erpnext.patches.v16_0.access_control_for_project_users

View File

@@ -0,0 +1,33 @@
import frappe
def execute():
Project = frappe.qb.DocType("Project")
ProjectUser = frappe.qb.DocType("Project User")
query = (
frappe.qb.from_(Project)
.join(ProjectUser)
.on(Project.name == ProjectUser.parent)
.select(Project.name, ProjectUser.user)
)
proj_users = query.run(as_dict=1)
project_mapped_users = get_project_mapped_users(proj_users)
for d in proj_users:
if d.user in project_mapped_users[d.name]:
continue
frappe.share.add_docshare("Project", d.name, user=d.user)
def get_project_mapped_users(proj_users):
projects = set([d.name for d in proj_users])
project_mapped_users = {}
for d in projects:
project_mapped_users[d] = [d.user for d in frappe.share.get_users("Project", d)]
return project_mapped_users

View File

@@ -86,7 +86,7 @@
"no_copy": 1,
"oldfieldname": "status",
"oldfieldtype": "Select",
"options": "Open\nCompleted\nCancelled",
"options": "Open\nOn hold\nCompleted\nCancelled",
"search_index": 1
},
{
@@ -121,7 +121,7 @@
"in_list_view": 1,
"label": "% Completed",
"no_copy": 1,
"read_only": 1
"read_only_depends_on": "eval:doc.percent_complete_method != 'Manual'"
},
{
"fieldname": "column_break_5",
@@ -210,13 +210,15 @@
"fieldname": "users",
"fieldtype": "Table",
"label": "Users",
"options": "Project User"
"options": "Project User",
"permlevel": 1
},
{
"fieldname": "copied_from",
"fieldtype": "Data",
"hidden": 1,
"label": "Copied From",
"permlevel": 1,
"read_only": 1
},
{
@@ -482,13 +484,25 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2026-05-22 16:45:50.762759",
"modified": "2026-07-21 11:23:22.000000",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"delete": 1,
"email": 1,
"export": 1,
"permlevel": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Projects Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,

View File

@@ -61,7 +61,7 @@ class Project(Document):
project_type: DF.Link | None
sales_order: DF.Link | None
second_email: DF.Time | None
status: DF.Literal["Open", "Completed", "Cancelled"]
status: DF.Literal["Open", "On hold", "Completed", "Cancelled"]
subject: DF.Data | None
to_time: DF.Time | None
total_billable_amount: DF.Currency
@@ -92,7 +92,8 @@ class Project(Document):
def validate(self):
if not self.is_new():
self.copy_from_template() # nosemgrep
self.copy_from_template()
self.control_access_for_project_users()
self.send_welcome_email()
self.update_costing()
self.update_percent_complete()
@@ -205,6 +206,7 @@ class Project(Document):
self.copy_from_template() # nosemgrep
if self.sales_order:
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
self.control_access_for_project_users()
def on_trash(self):
frappe.db.set_value("Sales Order", {"project": self.name}, "project", "")
@@ -220,6 +222,8 @@ class Project(Document):
if self.percent_complete_method == "Manual":
if self.status == "Completed":
self.percent_complete = 100
elif flt(self.percent_complete) < 0 or flt(self.percent_complete) > 100:
frappe.throw(_("% Complete must be between 0 and 100"))
return
total = frappe.db.count("Task", dict(project=self.name))
@@ -262,8 +266,8 @@ class Project(Document):
pct_complete += row["progress"] * frappe.utils.safe_div(row["task_weight"], weight_sum)
self.percent_complete = flt(flt(pct_complete), 2)
# don't update status if it is cancelled
if self.status == "Cancelled":
# don't update status if it is manually set to cancelled or on hold
if self.status in ("Cancelled", "On hold"):
return
self.status = "Completed" if self.percent_complete == 100 else "Open"
@@ -377,6 +381,34 @@ class Project(Document):
)
user.welcome_email_sent = 1
def control_access_for_project_users(self):
def revoke_access_for_project_users(removed_users):
users = set([d.user for d in frappe.share.get_users(self.doctype, self.name)])
for user in removed_users:
if user not in users:
continue
frappe.share.remove(self.doctype, self.name, user)
def grant_access_for_project_users(new_users):
for user in new_users:
frappe.share.add_docshare(self.doctype, self.name, user=user)
current_users = set([d.user for d in self.users])
old_doc = self.get_doc_before_save()
if not old_doc:
grant_access_for_project_users(current_users)
return
previous_users = set([d.user for d in old_doc.users])
new_users = current_users - previous_users
removed_users = previous_users - current_users
revoke_access_for_project_users(removed_users)
grant_access_for_project_users(new_users)
def get_timeline_data(doctype: str, name: str) -> dict[int, int]:
"""Return timeline for attendance"""

View File

@@ -4,6 +4,8 @@ frappe.listview_settings["Project"] = {
get_indicator: function (doc) {
if (doc.status == "Open" && doc.percent_complete) {
return [__("{0}%", [cint(doc.percent_complete)]), "orange", "percent_complete,>,0|status,=,Open"];
} else if (doc.status == "On hold") {
return [__("On hold"), "blue", "status,=,On hold"];
} else {
return [__(doc.status), frappe.utils.guess_colour(doc.status), "status,=," + doc.status];
}

View File

@@ -244,6 +244,116 @@ class TestProject(ERPNextTestSuite):
project.save()
self.assertEqual(project.status, "Completed")
def _project_with_tasks(self, method, count):
name = f"_Test PercentComplete {frappe.generate_hash(length=8)}"
project = frappe.get_doc(
{
"doctype": "Project",
"project_name": name,
"status": "Open",
"percent_complete_method": method,
"company": "_Test Company",
"expected_start_date": nowdate(),
}
).insert()
task_names = []
for i in range(count):
task = frappe.get_doc(
{
"doctype": "Task",
"subject": f"{name} Task {i}",
"project": project.name,
"status": "Open",
"exp_start_date": nowdate(),
"exp_end_date": nowdate(),
}
).insert()
task_names.append(task.name)
return project, task_names
def test_percent_complete_manual(self):
project, tasks = self._project_with_tasks("Manual", 2)
# manual value is preserved on save, even with linked tasks
project.percent_complete = 42
project.save()
self.assertEqual(project.percent_complete, 42)
# task updates do not overwrite the manual value
frappe.db.set_value("Task", tasks[0], "status", "Completed")
project.update_percent_complete()
self.assertEqual(project.percent_complete, 42)
# out-of-range values are rejected
project.percent_complete = 150
self.assertRaises(frappe.ValidationError, project.save)
project.reload()
project.percent_complete = -10
self.assertRaises(frappe.ValidationError, project.save)
project.reload()
# Completed status forces 100 regardless of the manual value
project.percent_complete = 42
project.status = "Completed"
project.save()
self.assertEqual(project.percent_complete, 100)
def _create_portal_user(self, email):
"""A user with no Project-related role, so read access can only come from
control_access_for_project_users() sharing the doc with them."""
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "Portal",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
return email
def test_new_project_grants_access_to_its_users(self):
member = self._create_portal_user(f"new_proj_member_{frappe.generate_hash(length=6)}@example.com")
project = frappe.get_doc(
doctype="Project",
project_name=f"_Test New Project Access {frappe.generate_hash(length=6)}",
status="Open",
company="_Test Company",
)
project.append("users", {"user": member, "welcome_email_sent": 1})
project.insert() # must not raise
self.assertTrue(project.has_permission(user=member))
shared_with = [d.user for d in frappe.share.get_users("Project", project.name)]
self.assertIn(member, shared_with)
def test_adding_and_removing_project_user_updates_access(self):
stays = self._create_portal_user(f"stays_{frappe.generate_hash(length=6)}@example.com")
leaves = self._create_portal_user(f"leaves_{frappe.generate_hash(length=6)}@example.com")
project = frappe.get_doc(
doctype="Project",
project_name=f"_Test Project User Membership {frappe.generate_hash(length=6)}",
status="Open",
company="_Test Company",
)
project.append("users", {"user": stays, "welcome_email_sent": 1})
project.insert()
self.assertTrue(project.has_permission(user=stays))
# adding a user on update (not insert) must also grant them access
project.append("users", {"user": leaves, "welcome_email_sent": 1})
project.save()
self.assertTrue(project.has_permission(user=leaves))
# removing a user must revoke the share that was granted for membership
project.users = [d for d in project.users if d.user != leaves]
project.save()
self.assertFalse(project.has_permission(user=leaves))
self.assertTrue(project.has_permission(user=stays))
def get_project(name, template):
project = frappe.get_doc(

View File

@@ -116,31 +116,37 @@ def get_data(filters, group_fieldname=None):
def group_by(data, fieldname):
groups = {row.get(fieldname) for row in data}
grouped_data = []
for group in sorted(groups):
group_row = {
fieldname: group,
"hours": sum(row.get("hours") for row in data if row.get(fieldname) == group),
"billing_hours": sum(row.get("billing_hours") for row in data if row.get(fieldname) == group),
"billing_amount": sum(row.get("billing_amount") for row in data if row.get(fieldname) == group),
"indent": 0,
"is_group": 1,
}
if fieldname == "employee":
group_row["employee_name"] = next(
row.get("employee_name") for row in data if row.get(fieldname) == group
)
groups = {}
for row in data:
groups.setdefault(row.get(fieldname), []).append(row)
grouped_data.append(group_row)
for row in data:
if row.get(fieldname) != group:
continue
grouped_data = []
for group in sorted(groups, key=lambda g: (g is None, g)):
hours = billing_hours = billing_amount = 0
child_rows = []
for row in groups[group]:
hours += row.get("hours") or 0
billing_hours += row.get("billing_hours") or 0
billing_amount += row.get("billing_amount") or 0
_row = row.copy()
_row[fieldname] = None
_row["indent"] = 1
_row["is_group"] = 0
grouped_data.append(_row)
child_rows.append(_row)
group_row = {
fieldname: group,
"hours": hours,
"billing_hours": billing_hours,
"billing_amount": billing_amount,
"indent": 0,
"is_group": 1,
}
if fieldname == "employee":
group_row["employee_name"] = groups[group][0].get("employee_name")
grouped_data.append(group_row)
grouped_data.extend(child_rows)
return grouped_data

View File

@@ -518,7 +518,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
return;
}
schedules.forEach((schedule) => (schedule.__checked = 1));
schedules.forEach((schedule) => {
schedule.__checked = 1;
schedule.currency = frm.doc.currency;
});
const dialog = new frappe.ui.Dialog({
title: __("Select Payment Schedule"),
@@ -552,10 +555,19 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
in_list_view: 1,
read_only: 1,
},
{
fieldtype: "Link",
fieldname: "currency",
label: __("Currency"),
options: "Currency",
hidden: 1,
read_only: 1,
},
{
fieldtype: "Currency",
fieldname: "payment_amount",
label: __("Amount"),
options: "currency",
in_list_view: 1,
read_only: 1,
},

View File

@@ -24,7 +24,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
@@ -273,6 +276,8 @@ class Customer(TransactionBase):
self.update_customer_groups()
link_portal_users_to_contacts(self)
def add_role_for_user(self):
for portal_user in self.portal_users:
add_role_for_portal_user(portal_user, "Customer")

View File

@@ -398,6 +398,33 @@ class TestCustomer(ERPNextTestSuite):
self.assertEqual(middle, "Michael")
self.assertEqual(last, "Doe")
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 Customer User"
user.send_welcome_email = False
user.insert(ignore_permissions=True)
contact = frappe.new_doc("Contact")
contact.first_name = "Test Portal Customer User"
contact.add_email(user_email, is_primary=1)
contact.links = []
contact.insert(ignore_permissions=True)
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "Test Portal Contact Customer",
"customer_type": "Individual",
}
)
customer.append("portal_users", {"user": user.name})
customer.insert()
contact.reload()
self.assertTrue(contact.has_link("Customer", customer.name))
def get_customer_dict(customer_name):
return {

View File

@@ -59,6 +59,11 @@ def get_data(
"reserved_qty": ["!=", 0],
"reserved_qty_for_production": ["!=", 0],
"reserved_qty_for_sub_contract": ["!=", 0],
"reserved_qty_for_production_plan": ["!=", 0],
"reserved_stock": ["!=", 0],
"ordered_qty": ["!=", 0],
"indented_qty": ["!=", 0],
"planned_qty": ["!=", 0],
"actual_qty": ["!=", 0],
},
filters=filters,

View File

@@ -3,17 +3,17 @@
frappe.ui.form.on("Bin", {
refresh(frm) {
frm.trigger("recalculate_bin_quantity");
frm.trigger("recalculate_values");
},
recalculate_bin_quantity(frm) {
frm.add_custom_button(__("Recalculate Bin Qty"), () => {
recalculate_values(frm) {
frm.add_custom_button(__("Recalculate Values"), () => {
frappe.call({
method: "recalculate_qty",
method: "recalculate_values",
freeze: true,
doc: frm.doc,
callback: function (r) {
frappe.show_alert(__("Bin Qty Recalculated"), 2);
frappe.show_alert(__("Bin Values Recalculated"), 2);
},
});
});

View File

@@ -37,7 +37,7 @@ class Bin(Document):
# end: auto-generated types
@frappe.whitelist()
def recalculate_qty(self):
def recalculate_values(self):
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
from erpnext.stock.stock_balance import (
get_indented_qty,
@@ -46,7 +46,19 @@ class Bin(Document):
get_reserved_qty,
)
self.actual_qty = get_actual_qty(self.item_code, self.warehouse)
last_sle = get_last_sle_values(self.item_code, self.warehouse)
self.actual_qty = last_sle.qty_after_transaction
self.valuation_rate = last_sle.valuation_rate
self.stock_value = last_sle.stock_value
from erpnext.stock.utils import get_valuation_method
if get_valuation_method(self.item_code) == "Standard Cost":
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
self.stock_value = flt(self.actual_qty) * flt(
get_item_standard_rate(self.item_code, self.company)
)
self.planned_qty = get_planned_qty(self.item_code, self.warehouse)
self.indented_qty = get_indented_qty(self.item_code, self.warehouse)
self.ordered_qty = get_ordered_qty(self.item_code, self.warehouse)
@@ -302,20 +314,23 @@ def update_qty(bin_name, args):
def get_actual_qty(item_code, warehouse):
return get_last_sle_values(item_code, warehouse).qty_after_transaction
def get_last_sle_values(item_code, warehouse):
sle = frappe.qb.DocType("Stock Ledger Entry")
last_sle_qty = (
last_sle = (
frappe.qb.from_(sle)
.select(sle.qty_after_transaction)
.select(sle.qty_after_transaction, sle.valuation_rate, sle.stock_value)
.where((sle.item_code == item_code) & (sle.warehouse == warehouse) & (sle.is_cancelled == 0))
.orderby(sle.posting_datetime, order=Order.desc)
.orderby(sle.creation, order=Order.desc)
.limit(1)
.run()
.run(as_dict=True)
)
actual_qty = 0.0
if last_sle_qty:
actual_qty = last_sle_qty[0][0]
if last_sle:
return last_sle[0]
return actual_qty
return frappe._dict(qty_after_transaction=0.0, valuation_rate=0.0, stock_value=0.0)

View File

@@ -26,6 +26,35 @@ class TestBin(ERPNextTestSuite):
bin = _create_bin(item_code, warehouse)
self.assertEqual(bin.item_code, item_code)
def test_recalculate_values(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
bin.db_set({"actual_qty": 0, "valuation_rate": 0, "stock_value": 0})
bin.reload()
bin.recalculate_values()
self.assertEqual(bin.actual_qty, 10)
self.assertEqual(bin.valuation_rate, 100)
self.assertEqual(bin.stock_value, 1000)
def test_recalculate_values_without_sle(self):
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
bin = _create_bin(item_code, warehouse)
bin.db_set({"actual_qty": 5, "valuation_rate": 50, "stock_value": 250})
bin.reload()
bin.recalculate_values()
self.assertEqual(bin.actual_qty, 0)
self.assertEqual(bin.valuation_rate, 0)
self.assertEqual(bin.stock_value, 0)
def test_index_exists(self):
indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1)
if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes):

View File

@@ -100,7 +100,10 @@ frappe.ui.form.on("Material Request", {
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
if (!frm.doc.buying_price_list) {
frm.doc.buying_price_list = frappe.defaults.get_default("buying_price_list");
const buying_price_list = frappe.defaults.get_default("buying_price_list");
if (frappe.has_permission("Price List", "read", buying_price_list)) {
frm.set_value("buying_price_list", buying_price_list);
}
}
},
@@ -287,9 +290,7 @@ frappe.ui.form.on("Material Request", {
from_warehouse: item.from_warehouse,
warehouse: item.warehouse,
doctype: frm.doc.doctype,
buying_price_list: frm.doc.buying_price_list
? frm.doc.buying_price_list
: frappe.defaults.get_default("buying_price_list"),
buying_price_list: frm.doc.buying_price_list,
currency: frappe.defaults.get_default("Currency"),
name: frm.doc.name,
qty: item.qty || 1,

View File

@@ -18,6 +18,7 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_se
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.stock.get_item_details import get_price_list_rate_for
from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty
from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
get_subcontracting_boms_for_finished_goods,
@@ -188,8 +189,46 @@ class MaterialRequest(BuyingController):
self.validate_pp_qty()
if self.buying_price_list and not frappe.get_value("Price List", self.buying_price_list, "buying"):
self.buying_price_list = None
if not self.buying_price_list:
self.buying_price_list = frappe.defaults.get_defaults().buying_price_list
buying_price_list = frappe.defaults.get_defaults().buying_price_list
if frappe.has_permission("Price List", "read", buying_price_list):
self.buying_price_list = buying_price_list
def on_update(self):
if not self.is_new() and self.buying_price_list and self.has_value_changed("buying_price_list"):
self.update_item_rates()
def update_item_rates(self):
price_not_uom_dependent = frappe.get_value(
"Price List", self.buying_price_list, "price_not_uom_dependent"
)
for item in self.items:
rate = get_price_list_rate_for(
frappe._dict(
{
"price_list": self.buying_price_list,
"uom": item.uom,
"transaction_date": self.transaction_date,
"qty": item.qty,
"stock_uom": item.stock_uom,
"conversion_factor": item.conversion_factor,
"price_list_uom_dependant": price_not_uom_dependent,
}
),
item.item_code,
)
if rate is not None:
item.db_set({"rate": rate, "amount": flt(rate * item.qty, item.precision("amount"))})
frappe.msgprint(
_("Item rates have been updated based on the selected Buying Price List {0}").format(
self.buying_price_list
),
alert=True,
)
def validate_pp_qty(self):
items_from_pp = [item for item in self.items if item.material_request_plan_item]

View File

@@ -600,8 +600,13 @@ def get_recipients():
return recipients
REPOSTING_JOB_ID_PREFIX = "repost_item_valuation_entry_"
def run_parallel_reposting():
# This function is called every 15 minutes via hooks.py
# This function is called every 15 minutes via hooks.py as a recovery net;
# each reposting job re-triggers it on completion to pick the next queued
# entry, so the queue drains continuously without waiting for the cron
if not frappe.db.get_single_value("Stock Reposting Settings", "enable_parallel_reposting"):
return
@@ -609,26 +614,17 @@ def run_parallel_reposting():
if not in_configured_timeslot():
return
items = set()
no_of_parallel_reposting = (
frappe.db.get_single_value("Stock Reposting Settings", "no_of_parallel_reposting") or 4
)
riv_entries = get_repost_item_valuation_entries()
rq_jobs = frappe.get_all(
"RQ Job",
fields=["arguments"],
filters={
"status": ("like", "%started%"),
"job_name": "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.execute_reposting_entry",
},
)
riv_entries = get_repost_item_valuation_entries(limit=no_of_parallel_reposting * 100)
entries_in_progress = get_entries_with_active_jobs()
items = get_items_with_active_reposting(entries_in_progress)
for row in riv_entries:
if rq_jobs:
if job_running_for_entry(row.name, rq_jobs):
continue
if row.name in entries_in_progress:
continue
if row.based_on != "Item and Warehouse" or row.repost_only_accounting_ledgers:
execute_reposting_entry(row.name)
@@ -641,12 +637,52 @@ def run_parallel_reposting():
if len(items) > no_of_parallel_reposting:
break
frappe.enqueue(
execute_reposting_entry,
name=row.name,
queue="long",
timeout=1800,
)
enqueue_reposting_entry(row.name)
def enqueue_reposting_entry(name):
frappe.enqueue(
execute_reposting_entry,
name=name,
continue_reposting=True,
queue="long",
timeout=1800,
job_id=f"{REPOSTING_JOB_ID_PREFIX}{name}",
deduplicate=True,
)
def enqueue_parallel_reposting():
frappe.enqueue(
run_parallel_reposting,
queue="long",
timeout=1800,
job_id="run_parallel_reposting",
deduplicate=True,
)
def get_entries_with_active_jobs() -> set:
from frappe.utils.background_jobs import get_queue
queue = get_queue("long")
job_ids = list(queue.get_job_ids()) + list(queue.started_job_registry.get_job_ids())
prefix = f"{frappe.local.site}||{REPOSTING_JOB_ID_PREFIX}"
return {job_id[len(prefix) :] for job_id in job_ids if job_id.startswith(prefix)}
def get_items_with_active_reposting(entries_in_progress) -> set:
if not entries_in_progress:
return set()
items = frappe.get_all(
"Repost Item Valuation",
filters={"name": ("in", list(entries_in_progress))},
pluck="item_code",
)
return {item_code for item_code in items if item_code}
def repost_entries():
@@ -664,7 +700,15 @@ def repost_entries():
execute_reposting_entry(row.name)
def execute_reposting_entry(name):
def execute_reposting_entry(name, continue_reposting=False):
try:
_execute_reposting_entry(name)
finally:
if continue_reposting:
enqueue_parallel_reposting()
def _execute_reposting_entry(name):
doc = frappe.get_doc("Repost Item Valuation", name)
if (
doc.repost_only_accounting_ledgers
@@ -679,7 +723,7 @@ def execute_reposting_entry(name):
doc.deduplicate_similar_repost()
def get_repost_item_valuation_entries():
def get_repost_item_valuation_entries(limit=None):
doctype = frappe.qb.DocType("Repost Item Valuation")
query = (
@@ -695,6 +739,9 @@ def get_repost_item_valuation_entries():
.orderby(doctype.status, order=frappe.qb.asc)
)
if limit:
query = query.limit(cint(limit))
return query.run(as_dict=True)
@@ -778,19 +825,3 @@ def get_existing_reposting_only_gl_entries(reposting_reference):
reposting_map[key] = d.reposting_reference
return reposting_map
def job_running_for_entry(reposting_entry, rq_jobs):
for job in rq_jobs:
if not job.arguments:
continue
try:
job_args = json.loads(job.arguments)
except (TypeError, json.JSONDecodeError):
continue
if isinstance(job_args, dict) and job_args.get("kwargs", {}).get("name") == reposting_entry:
return True
return False

View File

@@ -2,7 +2,7 @@
# See license.txt
from unittest.mock import MagicMock, call
from unittest.mock import MagicMock, call, patch
import frappe
from frappe.utils import add_days, add_to_date, now, nowdate, today
@@ -13,7 +13,11 @@ from erpnext.controllers.stock_controller import create_item_wise_repost_entries
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import (
REPOSTING_JOB_ID_PREFIX,
enqueue_reposting_entry,
execute_reposting_entry,
in_configured_timeslot,
run_parallel_reposting,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.tests.test_utils import StockTestMixin
@@ -510,3 +514,80 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
"name",
)
)
@ERPNextTestSuite.change_settings(
"Stock Reposting Settings",
{"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2},
)
def test_parallel_reposting_excludes_items_with_active_jobs(self):
module = "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation"
entries = [
frappe._dict(
name="RIV-1",
based_on="Item and Warehouse",
item_code="ITEM-A",
repost_only_accounting_ledgers=0,
),
frappe._dict(
name="RIV-2",
based_on="Item and Warehouse",
item_code="ITEM-A",
repost_only_accounting_ledgers=0,
),
frappe._dict(
name="RIV-3", based_on="Transaction", item_code=None, repost_only_accounting_ledgers=0
),
frappe._dict(
name="RIV-4",
based_on="Item and Warehouse",
item_code="ITEM-B",
repost_only_accounting_ledgers=0,
),
frappe._dict(
name="RIV-5",
based_on="Item and Warehouse",
item_code="ITEM-C",
repost_only_accounting_ledgers=0,
),
]
with (
patch(f"{module}.get_repost_item_valuation_entries", return_value=entries) as entries_mock,
patch(f"{module}.get_entries_with_active_jobs", return_value={"RIV-1"}),
patch(f"{module}.get_items_with_active_reposting", return_value={"ITEM-A"}),
patch(f"{module}.execute_reposting_entry") as execute_mock,
patch(f"{module}.enqueue_reposting_entry") as enqueue_mock,
):
run_parallel_reposting()
entries_mock.assert_called_once_with(limit=200)
execute_mock.assert_called_once_with("RIV-3")
enqueue_mock.assert_called_once_with("RIV-4")
def test_reposting_entry_continues_with_next_batch(self):
module = "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation"
with (
patch(f"{module}._execute_reposting_entry", side_effect=Exception("boom")),
patch(f"{module}.enqueue_parallel_reposting") as chain_mock,
):
self.assertRaises(Exception, execute_reposting_entry, "RIV-X", continue_reposting=True)
chain_mock.assert_called_once()
with (
patch(f"{module}._execute_reposting_entry"),
patch(f"{module}.enqueue_parallel_reposting") as chain_mock,
):
execute_reposting_entry("RIV-X")
chain_mock.assert_not_called()
def test_enqueue_reposting_entry_is_deduplicated(self):
with patch("frappe.enqueue") as enqueue_mock:
enqueue_reposting_entry("RIV-X")
kwargs = enqueue_mock.call_args.kwargs
self.assertEqual(kwargs["job_id"], f"{REPOSTING_JOB_ID_PREFIX}RIV-X")
self.assertTrue(kwargs["deduplicate"])
self.assertTrue(kwargs["continue_reposting"])

View File

@@ -3151,7 +3151,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.process_loss_qty = flt(
(flt(self.fg_completed_qty) * flt(self.process_loss_percentage)) / 100
)
elif self.process_loss_qty and not self.process_loss_percentage:
elif self.process_loss_qty and self.fg_completed_qty:
self.process_loss_percentage = flt(
(flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100
)

View File

@@ -2943,6 +2943,28 @@ class TestStockEntry(ERPNextTestSuite):
self.assertEqual(se.items[2].qty, 4.5)
self.assertEqual(se.items[2].amount, 5)
def test_process_loss_percentage_resyncs_from_qty(self):
# changing fg qty recomputes process_loss_qty
se = frappe.new_doc("Stock Entry")
se.purpose = "Manufacture"
se.fg_completed_qty = 200
se.process_loss_qty = 100
se.process_loss_percentage = 80
se.set_process_loss_qty()
self.assertEqual(se.process_loss_percentage, 50)
def test_process_loss_qty_derived_from_percentage_when_qty_blank(self):
se = frappe.new_doc("Stock Entry")
se.purpose = "Manufacture"
se.fg_completed_qty = 200
se.process_loss_percentage = 25
se.set_process_loss_qty()
self.assertEqual(se.process_loss_qty, 50)
def make_serialized_item(self, **args):
args = frappe._dict(args)

View File

@@ -697,6 +697,66 @@ class TestStockReservationEntry(ERPNextTestSuite):
# Test - 1: ValidationError should be thrown as the inwarded stock is reserved.
self.assertRaises(frappe.ValidationError, se.cancel)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
"allow_negative_stock": 0,
"enable_stock_reservation": 1,
"auto_reserve_serial_and_batch": 1,
"pick_serial_and_batch_based_on": "FIFO",
"use_serial_batch_fields": 1,
},
)
def test_batch_shared_across_sales_orders_can_be_delivered(self) -> None:
# Regression (#57159): one batch reserved by two Sales Orders. Delivering each order's own
# reserved unit must not raise Reserved Batch Conflict — the remainder covers the other order.
# The batch is set on the row explicitly as the v16 reserved-stock mapper does not carry
# the reserved batch onto the Delivery Note row.
item_doc = make_batch_item()
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
orders = []
for _i in range(2):
so = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=1, rate=100)
so.create_stock_reservation_entries()
orders.append(so)
(batch_no,) = get_reserved_batch_nos(orders[0].name) | get_reserved_batch_nos(orders[1].name)
for so in orders:
dn = make_delivery_note(so.name)
dn.items[0].batch_no = batch_no
dn.save()
dn.submit()
self.assertEqual(dn.docstatus, 1)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
"allow_negative_stock": 0,
"enable_stock_reservation": 1,
"auto_reserve_serial_and_batch": 1,
"pick_serial_and_batch_based_on": "FIFO",
"use_serial_batch_fields": 1,
},
)
def test_delivery_draining_a_batch_reserved_for_another_sales_order_is_blocked(self) -> None:
# Guard for #57159 fix: an order without a reservation must still be blocked from draining
# a batch below what another order has reserved from it, even if other batches have stock.
item_doc = make_batch_item()
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
so_a = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=2, rate=100)
so_a.create_stock_reservation_entries()
(reserved_batch_no,) = get_reserved_batch_nos(so_a.name)
so_b = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=2, rate=100)
dn = make_delivery_note(so_b.name)
dn.items[0].batch_no = reserved_batch_no
dn.save()
self.assertRaisesRegex(frappe.ValidationError, "is reserved for", dn.submit)
def create_items() -> dict:
items_properties = [
@@ -737,6 +797,33 @@ def create_items() -> dict:
return items
def make_batch_item():
return make_item(
properties={
"is_stock_item": 1,
"valuation_rate": 100,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "SRBI-.#####.",
}
)
def get_reserved_batch_nos(sales_order: str) -> set:
sre = frappe.qb.DocType("Stock Reservation Entry")
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
batch_nos = (
frappe.qb.from_(sre)
.inner_join(sb_entry)
.on(sre.name == sb_entry.parent)
.select(sb_entry.batch_no)
.where((sre.voucher_no == sales_order) & (sre.docstatus == 1))
).run(pluck=True)
return set(batch_nos)
def create_material_receipt(
items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100
) -> StockEntry:

View File

@@ -48,11 +48,19 @@ frappe.pages["stock-balance"].on_page_load = function (wrapper) {
sort_by: "projected_qty",
sort_order: "asc",
options: [
{ fieldname: "projected_qty", label: __("Projected qty") },
{ fieldname: "reserved_qty", label: __("Reserved for sale") },
{ fieldname: "reserved_qty_for_production", label: __("Reserved for manufacturing") },
{ fieldname: "reserved_qty_for_sub_contract", label: __("Reserved for sub contracting") },
{ fieldname: "actual_qty", label: __("Actual qty in stock") },
{ fieldname: "projected_qty", label: __("Projected Qty") },
{ fieldname: "reserved_qty", label: __("Reserved Qty") },
{ fieldname: "reserved_qty_for_production", label: __("Reserved Qty for Production") },
{ fieldname: "reserved_qty_for_sub_contract", label: __("Reserved Qty for Subcontract") },
{
fieldname: "reserved_qty_for_production_plan",
label: __("Reserved Qty for Production Plan"),
},
{ fieldname: "reserved_stock", label: __("Reserved Stock") },
{ fieldname: "ordered_qty", label: __("Ordered Qty") },
{ fieldname: "indented_qty", label: __("Requested Qty") },
{ fieldname: "planned_qty", label: __("Planned Qty") },
{ fieldname: "actual_qty", label: __("Actual Qty") },
],
},
change: function (sort_by, sort_order) {

View File

@@ -287,6 +287,7 @@ class FIFOSlots:
self.serial_no_details = {}
self.batch_no_details = {}
self.batchwise_valuation_by_batch = {}
self.valuation_method_by_item = {}
self.filters = filters
self.sle = sle
@@ -305,11 +306,13 @@ class FIFOSlots:
# prepare single sle voucher detail lookup
self.prepare_stock_reco_voucher_wise_count()
self.float_precision = get_float_precision()
if stock_ledger_entries is None:
# nested queries invalidate the streaming cursor below,
# so batchwise valuation flags must be resolved beforehand
# streaming path: nested queries invalidate the streaming cursor below,
# so batchwise valuation flags and item valuation methods must be resolved beforehand
self._prefetch_batchwise_valuations()
self._prefetch_valuation_methods()
with frappe.db.unbuffered_cursor():
if stock_ledger_entries is None:
@@ -321,12 +324,28 @@ class FIFOSlots:
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
del stock_ledger_entries
self._recompute_moving_average_slots()
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
self.item_details = self._aggregate_details_by_item(self.item_details)
return self.item_details
def _recompute_moving_average_slots(self) -> None:
for item_dict in self.item_details.values():
if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"):
continue
details = item_dict["details"]
if self._get_item_valuation_method(details.name) != "Moving Average":
continue
rate = flt(details.valuation_rate)
for slot in item_dict["fifo_queue"]:
if is_qty_slot(slot):
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
if stock_ledger_entries is not None:
return frappe._dict({}), frappe._dict({})
@@ -347,8 +366,12 @@ class FIFOSlots:
if row.actual_qty > 0:
self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
else:
self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
from_end = self._get_item_valuation_method(row.name) == "LIFO"
self._compute_outgoing_stock(
row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end
)
self._revalue_stock_reconciliation_slots(row, fifo_queue, batch_nos)
self._update_balances(row, key)
self._trim_serial_fifo_queue(row, key, fifo_queue)
@@ -372,6 +395,36 @@ class FIFOSlots:
# Stock reconciliation stores the final balance; FIFO needs the movement delta.
row.actual_qty = flt(row.qty_after_transaction) - flt(prev_balance_qty)
def _revalue_stock_reconciliation_slots(self, row: dict, fifo_queue: list, batch_nos: list) -> None:
if row.voucher_type != "Stock Reconciliation" or row.has_serial_no:
return
if row.has_batch_no:
if flt(row.actual_qty) > 0:
self._revalue_reconciled_batch_slots(fifo_queue, batch_nos)
return
for slot in fifo_queue:
if is_qty_slot(slot):
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * flt(row.valuation_rate))
def _revalue_reconciled_batch_slots(self, fifo_queue: list, batch_nos: list) -> None:
for batch_no, _use_batchwise_valuation, qty, stock_value_difference in batch_nos:
if not flt(qty):
continue
slots = [
slot
for slot in fifo_queue
if is_batch_slot(slot) and slot[BATCH_SLOT_BATCH_INDEX] == batch_no
]
if flt(sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) - flt(qty), self.float_precision):
continue
rate = flt(stock_value_difference) / flt(qty)
for slot in slots:
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
def _get_serial_and_batch_nos(
self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict
) -> tuple[list, list]:
@@ -460,6 +513,45 @@ class FIFOSlots:
for batch_no, use_batchwise_valuation in query.run():
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
def _get_item_valuation_method(self, item_code: str) -> str:
from erpnext.stock.utils import get_valuation_method
if item_code not in self.valuation_method_by_item:
# only reachable when stock ledger entries are passed in directly;
# the streaming path prefetches all methods before iteration
self.valuation_method_by_item[item_code] = get_valuation_method(
item_code, self.filters.get("company")
)
return self.valuation_method_by_item[item_code]
def _prefetch_valuation_methods(self) -> None:
from erpnext.stock.utils import get_valuation_method
company = self.filters.get("company")
sle = frappe.qb.DocType("Stock Ledger Entry")
item = frappe.qb.DocType("Item")
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
query = (
frappe.qb.from_(sle)
.inner_join(item)
.on(sle.item_code == item.name)
.select(item.name, item.valuation_method)
.distinct()
.where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1))
)
query = self._apply_filter(query, sle, "item_code")
# items with no item-level method share the company/settings default; resolve it once
default_method = None
for item_code, valuation_method in query.run():
if not valuation_method:
if default_method is None:
default_method = get_valuation_method(item_code, company)
valuation_method = default_method
self.valuation_method_by_item[item_code] = valuation_method
def _init_key_stores(self, row: dict) -> tuple:
"Initialise keys and FIFO Queue."
@@ -576,7 +668,13 @@ class FIFOSlots:
fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference)
def _compute_outgoing_stock(
self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list
self,
row: dict,
fifo_queue: list,
transfer_key: tuple,
serial_nos: list,
batch_nos: list,
from_end: bool = False,
):
"Update FIFO Queue on outward stock."
if serial_nos:
@@ -584,7 +682,7 @@ class FIFOSlots:
elif batch_nos:
self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos)
else:
self._consume_fifo_slots(row, fifo_queue, transfer_key)
self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end)
def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None:
fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos]
@@ -661,19 +759,23 @@ class FIFOSlots:
)
self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference])
def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None:
def _consume_fifo_slots(
self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False
) -> None:
# LIFO consumes the most recent inward first, so pop from the tail instead of the head.
index = -1 if from_end else 0
qty_to_pop = abs(row.actual_qty)
stock_value = abs(row.stock_value_difference)
while qty_to_pop:
slot = fifo_queue[0] if fifo_queue else [0, None, 0]
slot = fifo_queue[index] if fifo_queue else [0, None, 0]
slot_qty = flt(slot[FIFO_QTY_INDEX])
slot_value = flt(slot[FIFO_VALUE_INDEX])
if 0 < slot_qty <= qty_to_pop:
qty_to_pop -= slot_qty
stock_value -= slot_value
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
self.transferred_item_details[transfer_key].append(fifo_queue.pop(index))
elif not fifo_queue:
fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
self.transferred_item_details[transfer_key].append(

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age
@@ -63,6 +65,131 @@ class TestStockAgeing(ERPNextTestSuite):
data = format_report_data(self.filters, slots, self.filters["to_date"])
self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30
def test_moving_average_value_ties_to_stock_balance(self):
"""For Moving Average items the queue value is re-derived as qty * rate so the
report's stock value ties to Stock Balance, instead of stranding a residual
from FIFO-by-qty consumption vs blended outgoing value."""
sle = [
frappe._dict(
name="MA Item",
actual_qty=10,
qty_after_transaction=10,
stock_value_difference=1000,
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="MA Item",
actual_qty=10,
qty_after_transaction=20,
stock_value_difference=2000,
valuation_rate=150,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="MA Item",
actual_qty=(-10),
qty_after_transaction=10,
stock_value_difference=(-1500),
valuation_rate=150,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="MA Item",
actual_qty=(-5),
qty_after_transaction=5,
stock_value_difference=(-750),
valuation_rate=150,
warehouse="WH 1",
posting_date="2021-12-04",
voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False,
serial_no=None,
),
]
with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"):
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["MA Item"]["fifo_queue"]
total_value = sum(slot[2] for slot in queue)
# Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150
self.assertEqual(total_value, 750.0)
def test_lifo_consumes_newest_first(self):
"""LIFO items consume the most recent inward first, so the oldest lot stays on
hand. The remaining queue, stock value and average age must reflect the older
stock, unlike the default FIFO which retains the newest lots."""
sle = [
frappe._dict(
name="LIFO Item",
actual_qty=30,
qty_after_transaction=30,
stock_value_difference=30,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="LIFO Item",
actual_qty=20,
qty_after_transaction=50,
stock_value_difference=20,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="LIFO Item",
actual_qty=(-10),
qty_after_transaction=40,
stock_value_difference=(-10),
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False,
serial_no=None,
),
]
with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"):
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["LIFO Item"]["fifo_queue"]
# newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10
self.assertEqual(queue[0][0], 30.0)
self.assertEqual(queue[-1][0], 10.0)
self.assertEqual(sum(slot[0] for slot in queue), 40.0)
self.assertEqual(sum(slot[2] for slot in queue), 40.0)
# average age skews older than the FIFO result (8.5) because the old lot is retained
self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75)
def test_insufficient_balance(self):
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
sle = [
@@ -252,6 +379,243 @@ class TestStockAgeing(ERPNextTestSuite):
self.assertEqual(queue, [[60.0, "2025-11-30", 60.0], [30.0, "2026-01-31", 30.0]])
self.assertEqual(report_data[0][7:15], [30.0, 30.0, 0.0, 0.0, 60.0, 60.0, 0.0, 0.0])
def test_stock_reco_revaluation_rescales_queue_values(self):
"Ledger (same wh): [+15 @ 100, reco reset >> 20 @ 50]"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=15,
qty_after_transaction=15,
stock_value_difference=1500,
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=0,
qty_after_transaction=20,
stock_value_difference=(-500),
valuation_rate=50,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Flask Item"]["fifo_queue"]
self.assertEqual(queue, [[15.0, "2021-12-01", 750.0], [5.0, "2021-12-02", 250.0]])
def test_stock_reco_with_split_out_and_in_sles_revalues_queue(self):
"Ledger (same wh): [+10 @ 100, reco out >> 0, reco in >> 12 @ 2]"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=10,
qty_after_transaction=10,
stock_value_difference=1000,
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=(-10),
qty_after_transaction=0,
stock_value_difference=(-1000),
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=12,
qty_after_transaction=12,
stock_value_difference=24,
valuation_rate=2,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Flask Item"]["fifo_queue"]
self.assertEqual(queue, [[10.0, "2021-12-01", 20.0], [2.0, "2021-12-02", 4.0]])
def test_stock_reco_decrease_rescales_slots_at_reco_rate(self):
"""Ledger (same wh): [+10 @ 100, +20 @ 250, reco reset >> 25 @ 220]
The valuation engine collapses the FIFO stack to qty_after * valuation_rate
on a reco, so remaining slot values follow the reco rate, not the lot rates."""
sle = [
frappe._dict(
name="Flask Item",
actual_qty=10,
qty_after_transaction=10,
stock_value_difference=1000,
valuation_rate=100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=20,
qty_after_transaction=30,
stock_value_difference=5000,
valuation_rate=200,
warehouse="WH 1",
posting_date="2021-12-02",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
serial_no=None,
),
frappe._dict(
name="Flask Item",
actual_qty=0,
qty_after_transaction=25,
stock_value_difference=(-500),
valuation_rate=220,
warehouse="WH 1",
posting_date="2021-12-03",
voucher_type="Stock Reconciliation",
voucher_no="003",
has_serial_no=False,
serial_no=None,
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Flask Item"]["fifo_queue"]
self.assertEqual(queue, [[5.0, "2021-12-01", 1100.0], [20.0, "2021-12-02", 4400.0]])
def test_batch_stock_reco_revaluation_rescales_slot_values(self):
"Ledger (same wh, batch B): [+10 @ 100, reco out >> 0, reco in >> 12 @ 2]"
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Reco Revaluation",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-RECO-REVALUE-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type=voucher_type,
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "Stock Entry", "001", 10, 10, 1000),
make_sle("2021-12-02", "Stock Reconciliation", "002", -10, 0, -1000),
make_sle("2021-12-02", "Stock Reconciliation", "002", 12, 12, 24),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 10.0, "2021-12-01", 20.0],
[batch_no, 1, 2.0, "2021-12-02", 4.0],
],
)
def test_partial_batch_reco_keeps_existing_slot_values(self):
"""Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12]
The reco entry qty (delta 2) does not cover the whole batch, so
stock_value_difference / qty is not the batch rate: skip the rescale."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Partial Batch Reco",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-PARTIAL-RECO-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_type, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type=voucher_type,
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "Stock Entry", "001", 10, 10, 1000),
make_sle("2021-12-02", "Stock Reconciliation", "002", 0, 12, -400),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 10.0, "2021-12-01", 1000.0],
[batch_no, 1, 2.0, "2021-12-01", 400.0],
],
)
def test_sequential_stock_reco_same_warehouse(self):
"""
Test back to back stock recos (same warehouse).

View File

@@ -214,7 +214,7 @@ def create_reposting_entries(rows: str | list, company: str):
"posting_date": sle.posting_date,
"posting_time": sle.posting_time,
"company": company,
"allow_nagative_stock": 1,
"allow_negative_stock": 1,
}
).submit()
@@ -260,7 +260,7 @@ def repost_based_on_transaction(rows, company=None, entries=None):
"posting_date": row.get("posting_date"),
"posting_time": row.get("posting_time"),
"company": company,
"allow_nagative_stock": 1,
"allow_negative_stock": 1,
"recalculate_valuation_rate": 1,
}
).submit()

View File

@@ -325,7 +325,7 @@ def create_reposting_entries(rows, item_code=None, warehouse=None):
"warehouse": warehouse or row.warehouse,
"posting_date": row.posting_date,
"posting_time": row.posting_time,
"allow_nagative_stock": 1,
"allow_negative_stock": 1,
}
).submit()

View File

@@ -84,6 +84,7 @@ def execute(filters=None):
bin.reserved_qty_for_production_plan,
bin.reserved_qty_for_sub_contract,
reserved_qty_for_pos,
bin.reserved_stock,
bin.projected_qty,
re_order_level,
re_order_qty,
@@ -200,6 +201,13 @@ def get_columns():
"width": 100,
"convertible": "qty",
},
{
"label": _("Reserved Stock"),
"fieldname": "reserved_stock",
"fieldtype": "Float",
"width": 100,
"convertible": "qty",
},
{
"label": _("Projected Qty"),
"fieldname": "projected_qty",
@@ -246,6 +254,7 @@ def get_bin_list(filters):
bin.reserved_qty_for_production,
bin.reserved_qty_for_sub_contract,
bin.reserved_qty_for_production_plan,
bin.reserved_stock,
bin.projected_qty,
)
.orderby(bin.item_code, bin.warehouse)

View File

@@ -0,0 +1,6 @@
<p>{{_("Dear")}} {{ full_name }},</p>
<p>{{_("Your email has been verified and your appointment has been confirmed for {0}").format(scheduled_time)}}.</p>
<p>{{_("We look forward to meeting you")}}.</p>
<br>
<p style="font-size: 85%;">{{_("This email was sent from {0}").format(site_url)}}</p>

View File

@@ -1,6 +1,7 @@
<p>{{_("Dear")}} {{ full_name }}{% if last_name %} {{ last_name}}{% endif %},</p>
<p>{{_("A new appointment has been created for you with {0}").format(site_url)}}.</p>
<p>{{_("Click on the link below to verify your email and confirm the appointment")}}.</p>
<p>{{_("This link is valid for {0} minutes").format(expiry_minutes)}}.</p>
<p style="margin: 30px 0px;">
<a href="{{ link }}" rel="nofollow" style="padding: 8px 20px; background-color: #7575ff; color: #fff; border-radius: 4px; text-decoration: none; line-height: 1; border-bottom: 3px solid rgba(0, 0, 0, 0.2); font-size: 14px; font-weight: 200;">{{ _("Verify Email") }}</a>

View File

@@ -6,21 +6,12 @@ import frappe
def get_context(context):
project_user = frappe.db.get_value(
"Project User",
{"parent": frappe.form_dict.project, "user": frappe.session.user},
["user", "view_attachments", "hide_timesheets"],
as_dict=True,
)
if frappe.session.user != "Administrator" and (not project_user or frappe.session.user == "Guest"):
raise frappe.PermissionError
project_user = validate_and_get_project_user(project=frappe.form_dict.project)
context.no_cache = 1
context.show_sidebar = True
project = frappe.get_doc("Project", frappe.form_dict.project)
project.has_permission("read")
project.tasks = get_tasks(
project.name, start=0, item_status="open", search=frappe.form_dict.get("search")
)
@@ -64,6 +55,22 @@ def get_tasks(project, start=0, search=None, item_status=None):
return list(filter(lambda x: not x.parent_task, tasks))
@frappe.whitelist()
def get_task_html(project: str, start: int = 0, item_status: str | None = None):
validate_and_get_project_user(project=project)
return frappe.render_template(
"erpnext/templates/includes/projects/project_tasks.html",
{
"doc": {
"name": project,
"project_name": project,
"tasks": get_tasks(project, start, item_status=item_status),
}
},
is_path=True,
)
def get_timesheets(project, start=0, search=None):
filters = {"project": project}
if search:
@@ -89,9 +96,28 @@ def get_timesheets(project, start=0, search=None):
return timesheets
@frappe.whitelist()
def get_timesheet_html(project: str, start: int = 0):
validate_and_get_project_user(project=project)
return frappe.render_template(
"erpnext/templates/includes/projects/project_timesheets.html",
{"doc": {"timesheets": get_timesheets(project, start)}},
is_path=True,
)
def get_attachments(project):
return frappe.get_all(
"File",
filters={"attached_to_name": project, "attached_to_doctype": "Project", "is_private": 0},
fields=["file_name", "file_url", "file_size"],
)
def validate_and_get_project_user(project: str):
project_doc = frappe.get_doc("Project", project)
project_doc.check_permission()
project_user = next((d for d in project_doc.users if d.user == frappe.session.user), None)
return project_user

View File

@@ -0,0 +1,72 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.templates.pages.projects import validate_and_get_project_user
from erpnext.tests.utils import ERPNextTestSuite
class TestProjectsPage(ERPNextTestSuite):
"""validate_and_get_project_user() gates the /projects portal page. It must raise
frappe.PermissionError for a user who can't read the Project, and otherwise return
that user's Project User row (or None if they're permitted but not listed as one --
e.g. an internal Projects Manager browsing the portal)."""
def _create_user(self, email):
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "Portal",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
return email
def test_raises_permission_error_for_user_without_access(self):
project = make_project({"project_name": f"_Test Portal Access {frappe.generate_hash(length=6)}"})
outsider = self._create_user(f"outsider_{frappe.generate_hash(length=6)}@example.com")
with self.set_user(outsider):
self.assertRaises(frappe.PermissionError, validate_and_get_project_user, project.name)
def test_allows_user_listed_as_project_user_and_returns_their_row(self):
# Being a Project User shares the Project with that user (see
# Project.control_access_for_project_users), which is what lets them past
# check_permission() here.
member = self._create_user(f"member_{frappe.generate_hash(length=6)}@example.com")
project = frappe.get_doc(
doctype="Project",
project_name=f"_Test Portal Access {frappe.generate_hash(length=6)}",
status="Open",
company="_Test Company",
)
project.append(
"users", {"user": member, "view_attachments": 1, "hide_timesheets": 1, "welcome_email_sent": 1}
)
project.insert()
with self.set_user(member):
project_user = validate_and_get_project_user(project.name)
self.assertIsNotNone(project_user)
self.assertEqual(project_user.user, member)
self.assertEqual(project_user.view_attachments, 1)
self.assertEqual(project_user.hide_timesheets, 1)
def test_allows_internally_permitted_user_not_listed_as_project_user(self):
# The permission gate must be the real permission system (check_permission()),
# not "is this user in the Project's users child table" -- a Projects Manager
# can open any project's portal page without ever being added as its user.
project = make_project({"project_name": f"_Test Portal Access {frappe.generate_hash(length=6)}"})
manager = self._create_user(f"manager_{frappe.generate_hash(length=6)}@example.com")
frappe.get_doc("User", manager).add_roles("Projects Manager")
with self.set_user(manager):
project_user = validate_and_get_project_user(project.name)
self.assertIsNone(project_user)

View File

@@ -47,7 +47,11 @@ def payment_app_import_guard():
msg = _("payments app is not installed. Please install it from {} or {}").format(
marketplace_link, github_link
)
if "payments" not in frappe.get_installed_apps():
frappe.throw(msg, title=_("Missing Payments App"), exc=frappe.AppNotInstalledError)
try:
yield
except ImportError:
frappe.throw(msg, title=_("Missing Payments App"))
frappe.throw(msg, title=_("Missing Payments App"), exc=frappe.AppNotInstalledError)

View File

@@ -3,9 +3,9 @@
import frappe
from apiclient.discovery import build
from frappe import _
from frappe.model.document import Document
from pyyoutube import Api, PyYouTubeException
class VideoSettings(Document):
@@ -28,7 +28,7 @@ class VideoSettings(Document):
def validate_youtube_api_key(self):
if self.enable_youtube_tracking and self.api_key:
try:
build("youtube", "v3", developerKey=self.api_key)
Api(api_key=self.api_key).get_i18n_languages(parts="snippet")
except Exception:
title = _("Failed to Authenticate the API key.")
self.log_error("Failed to authenticate API key")

View File

@@ -237,9 +237,9 @@ async function submit() {
frappe.show_alert(__("Appointment Created Successfully"));
}
setTimeout(() => {
let redirect_url = "/";
let redirect_url = "/book_appointment";
if (window.appointment_settings.success_redirect_url) {
redirect_url += window.appointment_settings.success_redirect_url;
redirect_url = `/${window.appointment_settings.success_redirect_url}`;
}
window.location.href = redirect_url;
}, 5000);

View File

@@ -4,6 +4,7 @@ import zoneinfo
import frappe
from frappe import _
from frappe.rate_limiter import rate_limit
from frappe.utils.data import get_system_timezone
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
@@ -18,7 +19,7 @@ def get_context(context):
def handle_appointment_booking_disabled():
if not frappe.get_single_value("Appointment Booking Settings", "enable_scheduling"):
if not frappe.get_single_value("Appointment Booking Settings", "enable_appointment_portal"):
frappe.redirect_to_message(
_("Appointment Scheduling Disabled"),
_("Appointment Scheduling has been disabled for this site"),
@@ -64,6 +65,8 @@ def get_appointment_slots(date, timezone):
)
holiday_list = frappe.get_doc("Holiday List", settings.holiday_list)
timeslots = get_available_slots_between(query_start_time, query_end_time, settings)
# fetch the day's booked slots once instead of querying per timeslot
booked_times = get_booked_slot_times_for(timeslots, settings.appointment_duration)
# Filter and convert timeslots
converted_timeslots = []
@@ -74,7 +77,7 @@ def get_appointment_slots(date, timezone):
converted_timeslots.append(dict(time=converted_timeslot, availability=False))
continue
# Check availability
if check_availabilty(timeslot, settings) and converted_timeslot >= now:
if is_slot_available(timeslot, booked_times, settings) and converted_timeslot >= now:
converted_timeslots.append(dict(time=converted_timeslot, availability=True))
else:
converted_timeslots.append(dict(time=converted_timeslot, availability=False))
@@ -100,7 +103,8 @@ def get_available_slots_between(query_start_time, query_end_time, settings):
return timeslots
@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True, methods=["POST"])
@rate_limit(limit=5, seconds=300)
def create_appointment(date, time, tz, contact):
handle_appointment_booking_disabled()
format_string = "%Y-%m-%d %H:%M:%S"
@@ -112,13 +116,13 @@ def create_appointment(date, time, tz, contact):
# Create a appointment document from form
appointment = frappe.new_doc("Appointment")
appointment.scheduled_time = scheduled_time
contact = json.loads(contact)
contact = frappe.parse_json(contact)
appointment.customer_name = contact.get("name", None)
appointment.customer_phone_number = contact.get("number", None)
appointment.customer_skype = contact.get("skype", None)
appointment.customer_details = contact.get("notes", None)
appointment.customer_email = contact.get("email", None)
appointment.status = "Open"
appointment.created_through_portal = 1
appointment.insert(ignore_permissions=True)
return appointment
@@ -148,8 +152,23 @@ def convert_to_system_timezone(guest_tz, datetimeobject):
return datetimeobject
def check_availabilty(timeslot, settings):
return frappe.db.count("Appointment", {"scheduled_time": timeslot}) < settings.number_of_agents
def get_booked_slot_times_for(timeslots, appointment_duration):
if not timeslots:
return []
from erpnext.crm.doctype.appointment.appointment import get_booked_slot_times
duration = datetime.timedelta(minutes=appointment_duration)
return get_booked_slot_times(min(timeslots) - duration, max(timeslots) + duration)
def is_slot_available(timeslot, booked_times, settings):
# mirror the server capacity check: count non-Closed appointments whose
# duration window overlaps this slot, without a per-slot query
duration = datetime.timedelta(minutes=settings.appointment_duration)
lower, upper = timeslot - duration, timeslot + duration
overlapping = sum(1 for booked in booked_times if lower < booked < upper)
return overlapping < settings.number_of_agents
def _is_holiday(date, holiday_list):

View File

@@ -12,7 +12,7 @@
</div>
{% else %}
<div class="alert alert-danger">
{{ _("Verification failed please check the link") }}
{{ message or _("Verification failed please check the link") }}
</div>
{% endif %}
{% endblock%}

View File

@@ -1,20 +1,58 @@
import frappe
from frappe.utils.verified_command import verify_request
from frappe import _
from frappe.utils import add_to_date, now_datetime
from frappe.utils.data import sha256_hash
from erpnext.crm.doctype.appointment.appointment import get_verification_link_expiry
def get_context(context):
if not verify_request():
key = frappe.form_dict.get("key")
if not key:
context.success = False
return context
email = frappe.form_dict["email"]
appointment_name = frappe.form_dict["appointment"]
appointment_name = frappe.db.get_value("Appointment", {"verification_token": sha256_hash(key)}, "name")
if not appointment_name:
context.success = False
context.message = _("This verification link is invalid. Please book the appointment again.")
return context
if email and appointment_name:
appointment = frappe.get_doc("Appointment", appointment_name)
appointment.set_verified(email)
appointment = frappe.get_doc("Appointment", appointment_name)
# report a settled status before expiry: a closed/verified appointment is
# more informative than a generic "expired" (and creation-based expiry would
# otherwise mask a sweeper-closed appointment)
if appointment.status == "Closed":
context.success = False
context.message = _("Appointment has been closed. Please book the appointment again.")
return context
if appointment.status == "Open":
context.success = True
context.message = _("Appointment is already verified.")
return context
else:
if now_datetime() > add_to_date(appointment.creation, minutes=get_verification_link_expiry()):
context.success = False
context.message = _("Verification link has expired.")
return context
verify_appointment(appointment)
# GET requests are rolled back at the end of the request unless this flag is set
frappe.local.flags.commit = True
context.success = True
return context
def verify_appointment(appointment):
# the signed link is the authorization; materializing the appointment
# (agent assignment) needs system privileges the Guest visitor lacks
visitor = frappe.session.user
try:
frappe.set_user("Administrator")
appointment.email_verified = True
appointment.status = "Open"
appointment.save(ignore_permissions=True)
finally:
frappe.set_user(visitor)

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