Compare commits

..

2 Commits

Author SHA1 Message Date
gsinghpal
00f7e90a3d feat(fusion_repairs): maintenance foundation - policy + priced auto-contracts on sale (Plan 1)
Plan 1 of fusion_maintenance, verified on the Westin Enterprise sandbox (westin-fr-test) via odoo shell. Maintenance policy (enabled/interval/flat fee/service product) on the equipment category + per-product fee override; contract gains fee/source/serial/policy/currency; fixed the dead _spawn_maintenance_contracts and wired it into the existing action_confirm (delivery-date anchor w/ fallback, two-regime serial dedup, fee resolution product->category); reminder email shows the flat fee; category form exposes the policy. Verified: trigger creates 1 priced contract (fee 149, next_due commitment+6mo, source=sale); idempotent on re-confirm; product override beats category; no contract when category not maintainable; fee renders as $149.00. v19.0.2.3.0.

NOTE: mail_template_data.xml is noupdate=1 -> the fee line loads on fresh install (the prod deploy) but NOT on -u of an already-installed system. The Westin prod-config test container (workers + log_level=warn) does not run --test-enable post_install tests (a pre-existing module load issue under the test phase), so behaviour was verified by odoo shell instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:55:49 -04:00
gsinghpal
903ceb10d0 fix(fusion_repairs): two install blockers found on first clean install (Westin Enterprise)
Surfaced by installing fusion_repairs into a westin-v19 clone (its first-ever clean install; cloud.md's 'installed locally' was stale). (1) Post-visit NPS mail template used url_encode(), which is NOT in Odoo 19's mail.template QWeb render context -> save-validation failed at install (ParseError 'issue with this value'); replaced with a string-method (.replace) fallback. (2) views/menus.xml defined menu_fusion_repairs_configuration AFTER the children referencing it as parent -> 'External ID not found in the system'; moved the parent definition above its children. fusion_repairs now installs cleanly (32 models, 11 templates) on the Enterprise stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:21:07 -04:00
30 changed files with 282 additions and 1008 deletions

View File

@@ -630,27 +630,8 @@ De-Racking → Final inspection → Shipping`
Columns are first-class — they always render in this exact order, never Columns are first-class — they always render in this exact order, never
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind` reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
(stored) in `_compute_area_kind` (`fusion_plating_jobs/models/fp_job_step.py`): (stored) from `work_centre.area_kind` with a fallback to a step-kind
`work_centre.area_kind` → else `recipe_node.kind_id.area_kind` (the dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
`fp.step.kind` taxonomy is authoritative; the legacy `_STEP_KIND_TO_AREA`
dict is gone) → else catch-all `'plating'`.
**Gating/"Ready for X" marker steps fall FORWARD (fixed 2026-06-02).** The
`fp.step.kind` named *Gating* has `code='gating'` **and `area_kind='receiving'`**.
A gating step is a non-physical "ready for the next stage" marker, so
mapping it to Receiving made a *mid-recipe* gate snap the job's card back
to the first column (Racking → "Ready for processing" jumped to Receiving,
so the job looked like it vanished). `_compute_area_kind` therefore detects
a gating step via the **stable `kind_id.code == 'gating'`** (never the
display name) and resolves its column to the **next non-gating step's** raw
area (so "Ready for processing" before plating shows in the **Plating**
column); if nothing real follows, it falls back to the last real stage.
Helpers: `_fp_is_gating_step`, `_fp_raw_area_kind` (own work_centre/kind
only — no look-ahead, avoids recursion), `_fp_resolve_area_kind`. **NB:**
`area_kind` is a STORED compute, so after changing this logic you must
force-recompute existing rows (`env['fp.job.step'].search([])._compute_area_kind()`
+ `flush_recordset(['area_kind'])` + commit) — a `-u`/restart alone leaves
old values stale.
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip, **Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide, Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
@@ -1866,9 +1847,9 @@ A 50-part job can have parts at several stages at once (10 Masking, 20 Plating,
3. **The Move Parts dialog was only wired into the DEPRECATED `shopfloor_tablet.js`** — the live `fp_job_workspace` had no move/advance action, so operators literally could not move partial parts. The "Send → <next>" action now lives in `job_workspace.js` (`getStepActions` advance descriptor → `onAdvanceStep` → `FpMovePartsDialog`). The dialog itself was slimmed (qty steppers, no keyboard; Transfer Type + To Location collapsed behind "More options"). If you add another operator surface, wire the advance action there too. 3. **The Move Parts dialog was only wired into the DEPRECATED `shopfloor_tablet.js`** — the live `fp_job_workspace` had no move/advance action, so operators literally could not move partial parts. The "Send → <next>" action now lives in `job_workspace.js` (`getStepActions` advance descriptor → `onAdvanceStep` → `FpMovePartsDialog`). The dialog itself was slimmed (qty steppers, no keyboard; Transfer Type + To Location collapsed behind "More options"). If you add another operator surface, wire the advance action there too.
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves. **Two non-obvious traps in `_fp_try_autofinish_on_drain` (both fixed 2026-06-02):** (1) it must guard on a real **OUTGOING** move (`move_ids` to a different step, `qty_moved > 0`), NOT `_fp_has_real_incoming()` — the FIRST/seeded stage (e.g. Racking) is fed by the `qty_at_step` seed, has no incoming move, and so never auto-finished when all its parts were sent forward. (2) It is **best-effort and gated**: `button_finish` still runs the required-step-input / sign-off / contract-review gates, so a step with an unrecorded required input (e.g. Racking's "Count the Parts") will NOT auto-finish on drain — it stays `in_progress` with `qty_at_step=0` ("running, 0 here → finish me") until the operator records the input and finishes. That's correct (can't complete a step missing compliance data); don't try to force auto-finish past the gates. 4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves.
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream. **Second, distinct trap (fixed 2026-06-02): the Move dialog's `_blockers_for_move` predecessor check must only flag unfinished steps STRICTLY BETWEEN `from_step` and `to_step` (`from_step.sequence < s.sequence < to_step.sequence`), NOT all steps before `to_step`.** The original `s.sequence < to_step.sequence` filter counted the `from_step` itself (which is in-progress *by definition* when you advance partial parts out of it) as an "unfinished predecessor" of the destination — so EVERY partial advance to a not-yet-started next step showed a hard "Predecessor not done: \<from_step\>" blocker and greyed out SEND (hit on WO-30061). The between-only rule allows the immediate-next advance, still blocks skip-ahead moves over incomplete intermediate stages, and leaves backward (rework) moves unblocked (empty range). 5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream.
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net. 6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
@@ -1880,12 +1861,6 @@ A 50-part job can have parts at several stages at once (10 Masking, 20 Plating,
Operators reported invisible (dark-on-dark) text in the workspace + "Cannot Finish Step" dialog under Odoo dark mode. Root causes + the rules: Operators reported invisible (dark-on-dark) text in the workspace + "Cannot Finish Step" dialog under Odoo dark mode. Root causes + the rules:
1. **Odoo's compiled backend CSS does NOT define the Bootstrap colour custom-properties — `var(--bs-body-color)`, `var(--bs-secondary-color)`, `var(--bs-tertiary-bg)`, `var(--bs-body-bg)` are REFERENCED but never DEFINED (verified 2026-06-02: 0 definitions for `--bs-body-color`/`--bs-secondary-color` in the live `web.assets_backend` text).** So **any `color: var(--bs-body-color, #hex)` resolves to the `#hex` fallback in BOTH light and dark mode** — a dark hex → invisible on a dark surface. (`var(--text-secondary, …)` is even worse — that var name is entirely made-up.) Odoo themes the backend via **runtime `[data-bs-theme="dark"]`** (Bootstrap 5.3) + SCSS literals, NOT via those CSS vars, and NOT via `prefers-color-scheme`. Do NOT colour custom text with `var(--bs-*)`. **Correct, verified options:** 1. **`var(--text-secondary, #333)` is a MADE-UP variable — it does not exist in Odoo, so it ALWAYS falls back to the hardcoded dark hex → invisible on dark backgrounds.** It was used 33× across `job_workspace.scss` + 5 component stylesheets. The real, dark-aware secondary-text variable is **`var(--bs-secondary-color)`** (CLAUDE.md rule 9 lists it). Never use `--text-secondary` / `--text-primary` / `--card-bg` etc. — those aren't Odoo vars.
- **Inherit** — omit `color:` entirely so the element takes the dialog/page theme colour. Proven: the finish-block dialog's title + `.o_fp_finish_block_list` items have no colour and ARE readable in both modes; the `.o_fp_finish_block_msg` line was the ONLY broken one because it set `color: var(--bs-body-color,…)`. Removing that one line fixed it. This is the simplest fix for dialog/modal text.
- **Translucent `rgba()` for tinted boxes** — e.g. `background: rgba(245,158,11,0.16)` (warning) / `rgba(128,128,128,0.12)` (neutral). Works over whatever the live theme background is. (`color-mix(…, var(--bs-body-bg))` does NOT work — `--bs-body-bg` is undefined, so the whole `color-mix` is invalid and dropped.)
- **Explicit `[data-bs-theme="dark"] .my-class { color: … }`** override with literal hex when you genuinely need a different value per theme.
- **Compile-time `$o-webclient-color-scheme == dark`** literals only work if the **dark bundle is actually served**; on entech the active mechanism is runtime `[data-bs-theme]`, so prefer inherit / rgba / `[data-bs-theme=dark]` selectors over the two-bundle approach for backend dialogs.
NOTE: ~33 muted-text usages across `job_workspace.scss` + 5 component stylesheets still use `var(--bs-secondary-color, #hex)` (undefined → dark hex). They're muted/secondary so less glaring, but technically wrong in dark mode — sweep them to one of the patterns above when touched.
2. **Odoo's bootstrap does NOT define the Bootstrap 5.3 `--bs-{color}-bg-subtle` / `--bs-{color}-text-emphasis` family.** Verified by grepping `web/static/lib/bootstrap/scss/_root.scss`: `--bs-tertiary-bg` and `--bs-secondary-color` exist; `--bs-warning-bg-subtle`, `--bs-danger-bg-subtle`, `--bs-warning-text-emphasis` are MISSING. So `var(--bs-warning-bg-subtle, #fef3c7)` just yields the bright hex fallback — useless for dark mode. **For tinted status banners (warning/danger/info), use `color-mix` over the live theme bg instead:** `background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg)); color: var(--bs-body-color);` — pale in light mode, dark-tinted in dark mode, readable in both, graceful-degrades to no-bg on ancient browsers. (`color-mix` works in `background-color` per the rule-8 note; keep it out of shorthands.) Solid accent elements (selected pills, priority dots) with `color: white` are fine as-is in both modes. 2. **Odoo's bootstrap does NOT define the Bootstrap 5.3 `--bs-{color}-bg-subtle` / `--bs-{color}-text-emphasis` family.** Verified by grepping `web/static/lib/bootstrap/scss/_root.scss`: `--bs-tertiary-bg` and `--bs-secondary-color` exist; `--bs-warning-bg-subtle`, `--bs-danger-bg-subtle`, `--bs-warning-text-emphasis` are MISSING. So `var(--bs-warning-bg-subtle, #fef3c7)` just yields the bright hex fallback — useless for dark mode. **For tinted status banners (warning/danger/info), use `color-mix` over the live theme bg instead:** `background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg)); color: var(--bs-body-color);` — pale in light mode, dark-tinted in dark mode, readable in both, graceful-degrades to no-bg on ancient browsers. (`color-mix` works in `background-color` per the rule-8 note; keep it out of shorthands.) Solid accent elements (selected pills, priority dots) with `color: white` are fine as-is in both modes.
3. **Confirmed-present, dark-aware Odoo vars to reach for:** `--bs-body-color` (primary text), `--bs-secondary-color` (muted text), `--bs-body-bg` / `--bs-tertiary-bg` (surfaces), `--bs-border-color`. The deliberate color-coded plant-card status chips (`_plant_card.scss` `.kind-*` / `.tag-*`) are light-bg + dark-text (readable in both modes, just bright on a dark card) — intentionally left as a color-coded set. 3. **Confirmed-present, dark-aware Odoo vars to reach for:** `--bs-body-color` (primary text), `--bs-secondary-color` (muted text), `--bs-body-bg` / `--bs-tertiary-bg` (surfaces), `--bs-border-color`. The deliberate color-coded plant-card status chips (`_plant_card.scss` `.kind-*` / `.tag-*`) are light-bg + dark-text (readable in both modes, just bright on a dark card) — intentionally left as a color-coded set.

View File

@@ -157,71 +157,33 @@ class FpJobStep(models.Model):
@api.depends( @api.depends(
'work_centre_id.area_kind', 'work_centre_id.area_kind',
'recipe_node_id.kind_id.area_kind', 'recipe_node_id.kind_id.area_kind',
'recipe_node_id.kind_id.code',
'sequence',
'job_id.step_ids.sequence',
'job_id.step_ids.work_centre_id.area_kind',
'job_id.step_ids.recipe_node_id.kind_id.area_kind',
'job_id.step_ids.recipe_node_id.kind_id.code',
) )
def _compute_area_kind(self): def _compute_area_kind(self):
"""Resolve the plant-view column this step belongs in. """Resolve the plant-view column this step belongs in.
Priority chain (non-gating steps): Priority chain:
1. work_centre.area_kind (explicit operator setup wins) 1. work_centre.area_kind (explicit operator setup wins)
2. recipe_node.kind_id.area_kind (kind taxonomy authoritative) 2. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
3. catch-all 'plating' (data integrity issue if we land here) 3. catch-all 'plating' (data integrity issue if we land here)
Gating/marker steps (kind `code == 'gating'` — the "Ready for X" The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind
steps) have NO physical location; the taxonomy maps them to now self-declares its area_kind, so the kind taxonomy IS the
'receiving', which made a mid-recipe gate snap the job's card back source of truth. See spec
to the first column (Racking -> "Ready for processing" jumped to 2026-05-24-shopfloor-live-step-fix-design.md Change 6.
Receiving, so the job looked like it vanished — 2026-06-02). A
gating step now FALLS FORWARD to the next non-gating step's column
(it's "ready for [that stage]"), keeping the card moving
left->right. If nothing real follows, it falls back to the last
real stage.
""" """
for step in self: for step in self:
step.area_kind = step._fp_resolve_area_kind() # 1. Explicit work_centre wins
if step.work_centre_id and step.work_centre_id.area_kind:
def _fp_raw_area_kind(self): step.area_kind = step.work_centre_id.area_kind
"""Area from this step's OWN work_centre / kind only — no look-ahead continue
and no dependence on the computed `area_kind` field (so the gating # 2. Kind taxonomy
fall-forward below can't recurse).""" node = step.recipe_node_id
self.ensure_one() if node and node.kind_id and node.kind_id.area_kind:
if self.work_centre_id and self.work_centre_id.area_kind: step.area_kind = node.kind_id.area_kind
return self.work_centre_id.area_kind continue
node = self.recipe_node_id # 3. Catch-all — only reached for orphaned steps (no
if node and node.kind_id and node.kind_id.area_kind: # work_centre AND no recipe_node).
return node.kind_id.area_kind step.area_kind = 'plating'
return 'plating'
def _fp_is_gating_step(self):
"""True for a 'Ready for X' marker step (no physical location).
Detected via the STABLE kind code, never the display name."""
self.ensure_one()
node = self.recipe_node_id
return bool(node and node.kind_id and node.kind_id.code == 'gating')
def _fp_resolve_area_kind(self):
"""Column for this step: its own raw area, EXCEPT a gating marker
falls forward to the next non-gating step's column."""
self.ensure_one()
if not self._fp_is_gating_step():
return self._fp_raw_area_kind()
siblings = self.job_id.step_ids
later = siblings.filtered(
lambda s: s.sequence > self.sequence and not s._fp_is_gating_step()
).sorted('sequence')
if later:
return later[0]._fp_raw_area_kind()
earlier = siblings.filtered(
lambda s: s.sequence < self.sequence and not s._fp_is_gating_step()
).sorted('sequence')
if earlier:
return earlier[-1]._fp_raw_area_kind()
return self._fp_raw_area_kind()
last_activity_at = fields.Datetime( last_activity_at = fields.Datetime(
string='Last Activity', string='Last Activity',
@@ -736,29 +698,19 @@ class FpJobStep(models.Model):
operator to finish manually (the board will show it "running, 0 operator to finish manually (the board will show it "running, 0
here", which reads as "finish me"). here", which reads as "finish me").
Fires for any step that actually moved parts OUT and drained to Only fires for steps that had REAL incoming parts — never an
zero — INCLUDING the first/seeded stage (its qty comes from the untouched first-step seed. Returns True if the step finished.
qty_at_step seed, not a real incoming move). Returns True if the
step finished.
""" """
self.ensure_one() self.ensure_one()
if self.state != 'in_progress': if self.state != 'in_progress':
return False return False
if not self._fp_has_real_incoming():
return False
# qty_at_step is a non-stored compute off the move rows — force a # qty_at_step is a non-stored compute off the move rows — force a
# re-read so we see the just-committed outgoing move. # re-read so we see the just-committed outgoing move.
self.invalidate_recordset(['qty_at_step']) self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step != 0: if self.qty_at_step != 0:
return False return False
# Guard: only auto-finish a step that genuinely moved parts OUT (a
# real outgoing move, excluding self-loop measurement moves). The
# earlier guard checked _fp_has_real_incoming() — the WRONG
# direction: the first/seeded stage (e.g. Racking) is fed by the
# qty_at_step seed, not an incoming move, so it never auto-finished
# when all its parts were sent forward. Checking for a real
# OUTGOING move covers the seeded first stage correctly.
if not self.move_ids.filtered(
lambda m: m.to_step_id != self and (m.qty_moved or 0) > 0):
return False
try: try:
self.button_finish() self.button_finish()
return True return True

View File

@@ -124,18 +124,8 @@ class FpTabletMoveController(http.Controller):
hasattr(to_step, '_fp_should_block_predecessors') hasattr(to_step, '_fp_should_block_predecessors')
and to_step._fp_should_block_predecessors() and to_step._fp_should_block_predecessors()
): ):
# Partial-flow (2026-06-02): only an unfinished step STRICTLY
# BETWEEN from_step and to_step blocks the move (you'd be skipping
# an incomplete intermediate stage). The from_step itself is
# in-progress BY DEFINITION when advancing partial parts out of
# it — counting it (or any earlier step) as an "unfinished
# predecessor" blocked every partial advance to a not-yet-started
# next step. Steps before from_step are irrelevant: the parts
# being moved are physically at from_step, ready for the next
# stage. Backward moves (rework: from > to) yield an empty range
# and are never predecessor-blocked.
unfinished = to_step.job_id.step_ids.filtered( unfinished = to_step.job_id.step_ids.filtered(
lambda s: from_step.sequence < s.sequence < to_step.sequence lambda s: s.sequence < to_step.sequence
and s.state not in ('done', 'skipped', 'cancelled') and s.state not in ('done', 'skipped', 'cancelled')
) )
if unfinished: if unfinished:

View File

@@ -806,25 +806,20 @@ $_ws-text-hex: #1d1d1f;
gap: 1rem; gap: 1rem;
} }
// NOTE: Odoo's backend CSS does NOT define --bs-body-color /
// --bs-secondary-color / --bs-*-bg as custom properties (verified: 0
// definitions in the compiled bundle — they're SCSS literals + two
// bundles + [data-bs-theme]). So var(--bs-body-color, #hex) ALWAYS
// resolves to the dark #hex fallback, in light AND dark mode. The fix
// for dialog text is to INHERIT the modal's theme-correct colour (the
// dialog title and the "Count the Parts" list items do exactly this and
// are readable in both modes). Tinted boxes use translucent rgba() so
// they work over whatever the live theme background is.
.o_fp_finish_block_step { .o_fp_finish_block_step {
font-size: 1.1rem; font-size: 1.1rem;
background-color: rgba(245, 158, 11, 0.16); color: var(--bs-body-color);
// Amber wash over the live theme bg — pale in light mode, dark-amber
// in dark mode (the -bg-subtle/-text-emphasis BS vars aren't defined
// in Odoo's bootstrap, so color-mix is the dark-aware path).
background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg));
padding: 0.7rem 1rem; padding: 0.7rem 1rem;
border-radius: 6px; border-radius: 6px;
border-left: 4px solid #f59e0b; border-left: 4px solid #f59e0b;
} }
.o_fp_finish_block_msg { .o_fp_finish_block_msg {
font-weight: 500; color: var(--bs-secondary-color, #333);
} }
.o_fp_finish_block_list { .o_fp_finish_block_list {
@@ -839,9 +834,9 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_finish_block_action_note { .o_fp_finish_block_action_note {
// Inherit text colour; translucent neutral box works in both themes. color: var(--bs-secondary-color, #555);
font-style: italic; font-style: italic;
padding: 0.6rem 0.8rem; padding: 0.6rem 0.8rem;
background: rgba(128, 128, 128, 0.12); background: var(--bs-tertiary-bg, #f3f4f6);
border-radius: 4px; border-radius: 4px;
} }

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'Fusion Authorizer & Sales Portal', 'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.10.1', 'version': '19.0.2.8.0',
'category': 'Sales/Portal', 'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """ 'description': """
@@ -64,14 +64,12 @@ This module provides external portal access for:
'data/portal_menu_data.xml', 'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml', 'data/ir_actions_server_data.xml',
'data/welcome_articles.xml', 'data/welcome_articles.xml',
'data/visit_data.xml',
# Views # Views
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
'views/assessment_views.xml', 'views/assessment_views.xml',
'views/loaner_checkout_views.xml', 'views/loaner_checkout_views.xml',
'views/pdf_template_views.xml', 'views/pdf_template_views.xml',
'views/visit_views.xml',
# Portal Templates # Portal Templates
'views/portal_templates.xml', 'views/portal_templates.xml',
'views/portal_assessment_express.xml', 'views/portal_assessment_express.xml',
@@ -81,7 +79,6 @@ This module provides external portal access for:
'views/portal_technician_templates.xml', 'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml', 'views/portal_book_assessment.xml',
'views/portal_page11_sign_templates.xml', 'views/portal_page11_sign_templates.xml',
'views/portal_visit.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [

View File

@@ -458,7 +458,6 @@ class AssessmentPortal(CustomerPortal):
'current_page': 1, 'current_page': 1,
'total_pages': 2, 'total_pages': 2,
'assessment': None, 'assessment': None,
'visit_id': kw.get('visit_id', ''),
'google_maps_api_key': google_maps_api_key, 'google_maps_api_key': google_maps_api_key,
} }
@@ -517,7 +516,6 @@ class AssessmentPortal(CustomerPortal):
'partner': partner, 'partner': partner,
'user': user, 'user': user,
'assessment': assessment, 'assessment': assessment,
'visit_id': kw.get('visit_id') or (assessment.visit_id.id if assessment.visit_id else ''),
'authorizers': authorizers, 'authorizers': authorizers,
'authorizers_json': authorizers_json, 'authorizers_json': authorizers_json,
'clients': clients, 'clients': clients,
@@ -632,30 +630,6 @@ class AssessmentPortal(CustomerPortal):
except Exception as e: except Exception as e:
_logger.error(f"Error saving Page 11 signature: {e}") _logger.error(f"Error saving Page 11 signature: {e}")
# ===== Visit-linked: defer SO creation to visit completion =====
# Started from a visit workspace: do NOT complete into a standalone
# sale order. Leave it as a draft linked to the visit so
# visit.action_complete_visit() groups the visit's ADP devices
# (combination-checked) into ONE ADP order. The Page 11 signature is
# already saved above; pre-generate its PDF so it is ready.
if assessment.visit_id and action == 'submit':
if assessment.signature_page_11 and assessment.consent_declaration_accepted:
try:
pdf_bytes = assessment.generate_template_pdf('Page 11')
if pdf_bytes:
import base64 as b64
assessment.write({
'signed_page_11_pdf': b64.b64encode(pdf_bytes),
'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf',
})
except Exception as pdf_e:
_logger.warning(f"Visit-linked Page 11 PDF generation failed (non-blocking): {pdf_e}")
_logger.info(
f"Express assessment {assessment.reference} saved to visit "
f"{assessment.visit_id.name} (completion deferred to visit)"
)
return request.redirect(f'/my/visit/{assessment.visit_id.id}')
# Handle navigation # Handle navigation
if action == 'submit': if action == 'submit':
# If already completed, we just saved consent/signature above -- redirect with success # If already completed, we just saved consent/signature above -- redirect with success
@@ -829,13 +803,6 @@ class AssessmentPortal(CustomerPortal):
def _build_express_assessment_vals(self, kw): def _build_express_assessment_vals(self, kw):
"""Build values dict from express form POST data""" """Build values dict from express form POST data"""
vals = {} vals = {}
# Visit linkage (assessment started from a visit workspace)
if kw.get('visit_id'):
try:
vals['visit_id'] = int(kw.get('visit_id'))
except (ValueError, TypeError):
pass
# Equipment type # Equipment type
if kw.get('equipment_type'): if kw.get('equipment_type'):
@@ -848,15 +815,7 @@ class AssessmentPortal(CustomerPortal):
vals['wheelchair_type'] = kw.get('wheelchair_type') vals['wheelchair_type'] = kw.get('wheelchair_type')
if kw.get('powerchair_type'): if kw.get('powerchair_type'):
vals['powerchair_type'] = kw.get('powerchair_type') vals['powerchair_type'] = kw.get('powerchair_type')
if kw.get('scooter_type'):
vals['scooter_type'] = kw.get('scooter_type')
if kw.get('scooter_max_range'):
vals['scooter_max_range'] = float(kw.get('scooter_max_range') or 0)
if kw.get('x_fc_power_home_accessible'):
vals['x_fc_power_home_accessible'] = kw.get('x_fc_power_home_accessible')
if kw.get('x_fc_power_home_access_notes'):
vals['x_fc_power_home_access_notes'] = kw.get('x_fc_power_home_access_notes')
# Float measurements # Float measurements
float_fields = [ float_fields = [
'rollator_handle_height', 'rollator_seat_height', 'rollator_handle_height', 'rollator_seat_height',

View File

@@ -2479,56 +2479,6 @@ class AuthorizerPortal(CustomerPortal):
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector') template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
return request.render(template, values) return request.render(template, values)
# ==========================================================================
# ASSESSMENT VISIT WORKSPACE (Phase 1b/3)
# ==========================================================================
@http.route('/my/visit/new', type='http', auth='user', website=True)
def visit_new(self, **kw):
"""Start a new assessment visit and open its workspace."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return request.redirect('/my')
visit = request.env['fusion.assessment.visit'].sudo().create({
'sales_rep_id': request.env.user.id,
})
return request.redirect('/my/visit/%s' % visit.id)
@http.route('/my/visit/<int:visit_id>', type='http', auth='user', website=True)
def visit_workspace(self, visit_id, **kw):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
return request.render('fusion_portal.portal_visit_workspace', {
'visit': visit,
'page_name': 'visit',
'error': kw.get('error'),
})
@http.route('/my/visit/<int:visit_id>/save', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_save_client(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if visit.exists():
visit.write({
'client_name': (post.get('client_name') or '').strip(),
'client_phone': (post.get('client_phone') or '').strip(),
'client_email': (post.get('client_email') or '').strip(),
'client_address': (post.get('client_address') or '').strip(),
'x_fc_income_under_mod_threshold': post.get('x_fc_income_under_mod_threshold') or 'unknown',
})
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/visit/<int:visit_id>/complete', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_complete(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
try:
visit.action_complete_visit()
except Exception as e:
_logger.warning("Visit %s completion failed: %s", visit_id, e)
return request.redirect('/my/visit/%s?error=%s' % (visit_id, str(e)))
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True) @http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
def accessibility_assessment_save(self, **post): def accessibility_assessment_save(self, **post):
"""Save an accessibility assessment and optionally create a Sale Order""" """Save an accessibility assessment and optionally create a Sale Order"""
@@ -2597,13 +2547,6 @@ class AuthorizerPortal(CustomerPortal):
if partner.is_authorizer: if partner.is_authorizer:
vals['authorizer_id'] = partner.id vals['authorizer_id'] = partner.id
# Link to a visit if this form was launched from the workspace.
if post.get('visit_id'):
try:
vals['visit_id'] = int(post.get('visit_id'))
except (TypeError, ValueError):
pass
# Create the assessment # Create the assessment
assessment = Assessment.create(vals) assessment = Assessment.create(vals)
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}") _logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
@@ -2629,12 +2572,8 @@ class AuthorizerPortal(CustomerPortal):
if video_data: if video_data:
self._attach_accessibility_video(assessment, video_data, video_filename) self._attach_accessibility_video(assessment, video_data, video_filename)
# Complete the assessment and create Sale Order if requested. # Complete the assessment and create Sale Order if requested
# When launched from a visit, always save as a draft linked to the
# visit — the VISIT completion creates the grouped sale order(s).
create_sale_order = post.get('create_sale_order', True) create_sale_order = post.get('create_sale_order', True)
if vals.get('visit_id'):
create_sale_order = False
if create_sale_order: if create_sale_order:
sale_order = assessment.action_complete() sale_order = assessment.action_complete()
return { return {
@@ -2647,13 +2586,12 @@ class AuthorizerPortal(CustomerPortal):
'redirect_url': f'/my/sales/case/{sale_order.id}', 'redirect_url': f'/my/sales/case/{sale_order.id}',
} }
else: else:
redirect_url = ('/my/visit/%s' % vals['visit_id']) if vals.get('visit_id') else '/my/accessibility/list'
return { return {
'success': True, 'success': True,
'assessment_id': assessment.id, 'assessment_id': assessment.id,
'assessment_ref': assessment.reference, 'assessment_ref': assessment.reference,
'message': f'Assessment {assessment.reference} saved.', 'message': f'Assessment {assessment.reference} saved as draft.',
'redirect_url': redirect_url, 'redirect_url': '/my/accessibility/list',
} }
except Exception as e: except Exception as e:

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Reference sequence for assessment visits (VISIT/2026/0001) -->
<record id="seq_fusion_assessment_visit" model="ir.sequence">
<field name="name">Assessment Visit</field>
<field name="code">fusion.assessment.visit</field>
<field name="prefix">VISIT/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -7,6 +7,5 @@ from . import adp_document
from . import assessment from . import assessment
from . import accessibility_assessment from . import accessibility_assessment
from . import sale_order from . import sale_order
from . import visit
from . import loaner_checkout from . import loaner_checkout
from . import pdf_template from . import pdf_template

View File

@@ -157,14 +157,7 @@ class FusionAccessibilityAssessment(models.Model):
readonly=True, readonly=True,
copy=False, copy=False,
) )
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this accessibility assessment belongs to.',
)
# Dates # Dates
assessment_date = fields.Date( assessment_date = fields.Date(
string='Assessment Date', string='Assessment Date',

View File

@@ -45,7 +45,6 @@ class FusionAssessment(models.Model):
('rollator', 'Rollator'), ('rollator', 'Rollator'),
('wheelchair', 'Wheelchair'), ('wheelchair', 'Wheelchair'),
('powerchair', 'Powerchair'), ('powerchair', 'Powerchair'),
('scooter', 'Mobility Scooter'),
], string='Equipment Type', tracking=True, index=True) ], string='Equipment Type', tracking=True, index=True)
# Rollator Types # Rollator Types
@@ -70,31 +69,6 @@ class FusionAssessment(models.Model):
('type_2', 'Adult Power Base Type 2'), ('type_2', 'Adult Power Base Type 2'),
('type_3', 'Adult Power Base Type 3'), ('type_3', 'Adult Power Base Type 3'),
], string='Powerchair Type') ], string='Powerchair Type')
# ===== MOBILITY SCOOTER (ADP) — 2026-06 Phase 2 =====
scooter_type = fields.Selection([
('travel_3', '3-Wheel Travel/Portable'),
('travel_4', '4-Wheel Travel/Portable'),
('standard_3', '3-Wheel Standard'),
('standard_4', '4-Wheel Standard'),
('heavy_duty', 'Heavy-Duty / Bariatric'),
], string='Scooter Type')
scooter_max_range = fields.Float(
string='Maximum Range Needed (km)', digits=(10, 1),
help='Maximum distance the client needs the scooter to travel on a charge.',
)
# ===== POWER-MOBILITY HOME ACCESSIBILITY (ADP hard rule) =====
# Applies to scooter + power wheelchair: ADP funds power mobility only if the
# device can enter and be used at the residence independently, without lifting.
x_fc_power_home_accessible = fields.Selection([
('yes', 'Yes — usable inside and outside independently'),
('no', 'No — home needs accessibility work'),
], string='Home accessible for power-mobility device?',
help='ADP will not fund a scooter / power wheelchair if the home cannot '
'take it (device left outside or in the garage). If No, the home '
'needs an accessibility product (ramp / porch lift).')
x_fc_power_home_access_notes = fields.Text(string='Home Access Notes')
# ===== EXPRESS FORM: ROLLATOR MEASUREMENTS ===== # ===== EXPRESS FORM: ROLLATOR MEASUREMENTS =====
rollator_handle_height = fields.Float(string='Handle Height (inches)', digits=(10, 2)) rollator_handle_height = fields.Float(string='Handle Height (inches)', digits=(10, 2))
@@ -451,15 +425,7 @@ class FusionAssessment(models.Model):
readonly=True, readonly=True,
copy=False, copy=False,
) )
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this ADP assessment belongs to (groups multiple '
'assessments / funding workflows from one visit).',
)
# ===== COMPUTED FIELDS ===== # ===== COMPUTED FIELDS =====
document_count = fields.Integer( document_count = fields.Integer(
string='Document Count', string='Document Count',
@@ -1486,18 +1452,20 @@ class FusionAssessment(models.Model):
}) })
def _send_completion_notifications(self): def _send_completion_notifications(self):
"""Notify the CLIENT that the assessment is complete. """Send email notifications when assessment is completed"""
The authorizer, sales rep and office are already emailed (with the full
assessment report) by ``_send_assessment_completed_email`` inside
``_create_draft_sale_order``. This method used to ALSO send the
authorizer a second, template-only email — that duplicate is removed;
here we only notify the client.
"""
self.ensure_one() self.ensure_one()
# Send to client (authorizer/rep/office already emailed by # Send to authorizer
# _send_assessment_completed_email in _create_draft_sale_order) if self.authorizer_id and self.authorizer_id.email:
try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=True)
_logger.info(f"Sent assessment completion email to authorizer {self.authorizer_id.email}")
except Exception as e:
_logger.error(f"Failed to send authorizer notification: {e}")
# Send to client
if self.client_email: if self.client_email:
try: try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False) template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False)

View File

@@ -53,17 +53,6 @@ class SaleOrder(models.Model):
'sale order — stair lift, VPL, ceiling lift, ramp, bathroom mod, ' 'sale order — stair lift, VPL, ceiling lift, ramp, bathroom mod, '
'or tub cutout visits.', 'or tub cutout visits.',
) )
# Link to the assessment visit (one visit -> one SO per funding workflow).
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
readonly=True,
index=True,
help='The home visit this sale order was generated from. A visit '
'produces one sale order per funding workflow (ADP / MOD / ODSP / '
'private / ...).',
)
# Authorizer helper field (consolidates multiple possible fields) # Authorizer helper field (consolidates multiple possible fields)
portal_authorizer_id = fields.Many2one( portal_authorizer_id = fields.Many2one(

View File

@@ -1,299 +0,0 @@
# -*- coding: utf-8 -*-
"""Assessment Visit — bundles the assessments done during one home visit.
A sales rep + occupational therapist visit a client for 30-45 min and may do
several assessments (an ADP wheelchair plus accessibility products like a stair
lift, ramp, or tub cutout). The Visit is the hub that holds the client/context
ONCE and, on completion, groups its assessments by FUNDING WORKFLOW
(x_fc_sale_type) and creates ONE draft sale order per workflow — never one
combined SO, and never a separate SO per item within the same funding.
See docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md.
"""
import logging
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Accessibility funding source -> sale.order x_fc_sale_type. Mirrors
# fusion.accessibility.assessment._create_draft_sale_order so a grouped Visit
# routes exactly the way a single accessibility assessment would.
ACCESSIBILITY_SALE_TYPE_MAP = {
'march_of_dimes': 'march_of_dimes',
'odsp': 'odsp',
'wsib': 'wsib',
'hardship': 'hardship',
'insurance': 'insurance',
'direct_private': 'direct_private',
'other': 'other',
}
class FusionAssessmentVisit(models.Model):
_name = 'fusion.assessment.visit'
_description = 'Assessment Visit'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'visit_date desc, id desc'
name = fields.Char(
string='Visit Reference', required=True, readonly=True,
copy=False, default=lambda self: _('New'),
)
state = fields.Selection(
selection=[
('measuring', 'Measuring'),
('client_pending', 'Client Details Pending'),
('done', 'Completed'),
('cancelled', 'Cancelled'),
],
default='measuring', tracking=True, copy=False,
)
# --- Shared client + context (entered once for the whole visit) ---------
partner_id = fields.Many2one('res.partner', string='Client', tracking=True)
client_name = fields.Char(string='Client Name', tracking=True)
client_phone = fields.Char(string='Phone')
client_email = fields.Char(string='Email')
client_address = fields.Char(string='Address')
visit_date = fields.Date(
string='Visit Date', default=fields.Date.context_today, tracking=True,
)
sales_rep_id = fields.Many2one(
'res.users', string='Sales Rep',
default=lambda self: self.env.user, tracking=True,
)
authorizer_id = fields.Many2one(
'res.partner', string='Occupational Therapist', tracking=True,
)
# --- March of Dimes funding context (informational; spec §4.1) ----------
# MOD covers up to $15,000 per person (lifetime), income-gated.
x_fc_income_under_mod_threshold = fields.Selection(
selection=[
('yes', 'Yes — under threshold (full $15k available)'),
('no', 'No — over threshold (may be denied / partial)'),
('unknown', 'Unknown'),
],
string='Income under MOD threshold?', default='unknown',
help='March of Dimes funds up to $15,000 per person (lifetime) when the '
"client's income is under that year's threshold; over it, MOD may "
'deny or partially approve. Reminder only — no automatic cap '
'enforcement in this version.',
)
# --- Assessments performed during this visit ----------------------------
adp_assessment_ids = fields.One2many(
'fusion.assessment', 'visit_id', string='ADP Assessments',
)
accessibility_assessment_ids = fields.One2many(
'fusion.accessibility.assessment', 'visit_id',
string='Accessibility Assessments',
)
# --- Sale orders produced — one per funding workflow --------------------
sale_order_ids = fields.One2many(
'sale.order', 'visit_id', string='Sale Orders',
)
assessment_count = fields.Integer(compute='_compute_counts')
sale_order_count = fields.Integer(compute='_compute_counts')
has_mod_items = fields.Boolean(compute='_compute_has_mod_items')
@api.depends('adp_assessment_ids', 'accessibility_assessment_ids', 'sale_order_ids')
def _compute_counts(self):
for visit in self:
visit.assessment_count = (
len(visit.adp_assessment_ids)
+ len(visit.accessibility_assessment_ids)
)
visit.sale_order_count = len(visit.sale_order_ids)
@api.depends('accessibility_assessment_ids.x_fc_funding_source')
def _compute_has_mod_items(self):
for visit in self:
visit.has_mod_items = any(
a.x_fc_funding_source == 'march_of_dimes'
for a in visit.accessibility_assessment_ids
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals['name'] == _('New'):
seq = self.env['ir.sequence'].next_by_code('fusion.assessment.visit')
vals['name'] = seq or _('New')
return super().create(vals_list)
# ------------------------------------------------------------------
# Completion — group assessments by funding workflow → one SO each
# ------------------------------------------------------------------
def _ensure_partner(self):
"""Resolve the client partner for the visit, reusing an assessment's
partner/_ensure_partner when one is already set."""
self.ensure_one()
if self.partner_id:
return self.partner_id
# Borrow a child assessment's partner resolution if available.
for assessment in self.accessibility_assessment_ids:
if assessment.partner_id:
return assessment.partner_id
if hasattr(assessment, '_ensure_partner'):
return assessment._ensure_partner()
for assessment in self.adp_assessment_ids:
if assessment.partner_id:
return assessment.partner_id
if self.client_name:
return self.env['res.partner'].sudo().create({
'name': self.client_name,
'phone': self.client_phone or False,
'email': self.client_email or False,
})
raise UserError(_('Set a client (or client name) on the visit first.'))
def _create_grouped_sale_order(self, partner, sale_type, accessibility_assessments):
"""Create ONE draft sale order for a set of same-funding accessibility
assessments, link them all to it, and post each one's spec to chatter.
Mirrors fusion.accessibility.assessment._create_draft_sale_order but for
a group sharing one funding workflow."""
self.ensure_one()
SaleOrder = self.env['sale.order'].sudo()
so_vals = {
'partner_id': partner.id,
'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id,
'state': 'draft',
'origin': _('Visit %s (%s)') % (self.name, sale_type),
'x_fc_sale_type': sale_type,
'visit_id': self.id,
}
if self.authorizer_id:
so_vals['x_fc_authorizer_id'] = self.authorizer_id.id
# MOD: pre-fill the accessibility specialist from the sales rep.
if sale_type == 'march_of_dimes' and self.sales_rep_id and self.sales_rep_id.partner_id:
so_vals['x_fc_mod_accessibility_specialist_id'] = self.sales_rep_id.partner_id.id
sale_order = SaleOrder.create(so_vals)
for assessment in accessibility_assessments:
assessment.sale_order_id = sale_order.id
assessment._add_assessment_tag(sale_order)
sale_order.message_post(
body=Markup(assessment._format_assessment_html_table()),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
assessment.write({'state': 'completed'})
# One completion notification per SO (not per assessment) — mirrors the
# standalone accessibility completion's office email.
if accessibility_assessments:
try:
accessibility_assessments[0]._send_completion_email(sale_order)
except Exception as e:
_logger.warning(
"Visit %s: completion email failed for %s: %s",
self.name, sale_order.name, e,
)
_logger.info(
"Visit %s created %s sale order %s grouping %d accessibility assessment(s)",
self.name, sale_type, sale_order.name, len(accessibility_assessments),
)
return sale_order
def _validate_adp_combination(self, adp_assessments):
"""Enforce ADP device-combination rules: at most one seated-mobility
device (manual wheelchair / power wheelchair / scooter), optionally one
walker/rollator, no duplicates."""
seated_types = {'wheelchair', 'powerchair', 'scooter'}
seated = [a for a in adp_assessments if a.equipment_type in seated_types]
walkers = [a for a in adp_assessments if a.equipment_type == 'rollator']
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
if len(seated) > 1:
raise UserError(_(
'An ADP order can include only one seated-mobility device '
'(manual wheelchair, power wheelchair, or scooter). This visit has: %s.'
) % ', '.join(labels.get(a.equipment_type, a.equipment_type) for a in seated))
if len(walkers) > 1:
raise UserError(_('An ADP order can include only one walker / rollator.'))
def _assessment_sale_type(self, adp_assessment):
"""Funding workflow key for an ADP equipment assessment, mirroring
fusion.assessment._create_draft_sale_order: ADP+ODSP when the client
type is an ODSP stream, plain ADP otherwise. ADP devices that share a
key are grouped onto one sale order."""
if adp_assessment.client_type in ('ods', 'acs', 'owp'):
return 'adp_odsp'
return 'adp'
def action_complete_visit(self):
"""Group the visit's accessibility assessments by funding workflow and
create one draft SO per workflow. ADP equipment assessments keep their
existing one-assessment-one-SO completion for now (ADP multi-device
grouping arrives in Phase 2)."""
self.ensure_one()
if self.state == 'done':
raise UserError(_('This visit is already completed.'))
if not (self.accessibility_assessment_ids or self.adp_assessment_ids):
raise UserError(_('Add at least one assessment before completing the visit.'))
partner = self._ensure_partner()
# Group accessibility assessments by their funding -> sale type.
by_sale_type = {}
for assessment in self.accessibility_assessment_ids:
if assessment.sale_order_id:
continue # already has an SO; don't duplicate
sale_type = ACCESSIBILITY_SALE_TYPE_MAP.get(
assessment.x_fc_funding_source, 'direct_private')
by_sale_type.setdefault(sale_type, []).append(assessment)
for sale_type, group in by_sale_type.items():
self._create_grouped_sale_order(partner, sale_type, group)
# ADP equipment assessments: one ADP order per funding type, with the
# device-combination guard, reusing the existing (prod-tested) express
# completion. The first device creates the SO; the rest attach to it.
adp_by_type = {}
for assessment in self.adp_assessment_ids:
if assessment.sale_order_id:
continue
adp_by_type.setdefault(self._assessment_sale_type(assessment), []).append(assessment)
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
for sale_type, group in adp_by_type.items():
self._validate_adp_combination(group)
# Make sure each device carries the visit's client + OT so the
# existing completion logic has what it needs.
for assessment in group:
vals = {}
if not assessment.client_name:
vals['client_name'] = self.client_name or partner.name
if not assessment.authorizer_id and self.authorizer_id:
vals['authorizer_id'] = self.authorizer_id.id
if not assessment.partner_id:
vals['partner_id'] = partner.id
if vals:
assessment.write(vals)
primary = group[0]
sale_order = primary.action_complete_express()
sale_order.write({'visit_id': self.id, 'x_fc_sale_type': sale_type})
for extra in group[1:]:
extra.write({'state': 'completed', 'sale_order_id': sale_order.id})
sale_order.message_post(
body=Markup('<p><strong>Additional ADP device on this order:</strong> %s</p>')
% labels.get(extra.equipment_type, extra.equipment_type or 'device'),
subtype_xmlid='mail.mt_note',
)
self.write({'state': 'done', 'partner_id': partner.id})
return self._action_view_sale_orders()
def _action_view_sale_orders(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Visit Sale Orders'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('visit_id', '=', self.id)],
'context': {'create': False},
}

View File

@@ -7,8 +7,6 @@ access_fusion_assessment_user,fusion.assessment.user,model_fusion_assessment,bas
access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0 access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0
access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1 access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1
access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0 access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0
access_fusion_assessment_visit_user,fusion.assessment.visit.user,model_fusion_assessment_visit,base.group_user,1,1,1,1
access_fusion_assessment_visit_portal,fusion.assessment.visit.portal,model_fusion_assessment_visit,base.group_portal,1,1,1,0
access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1 access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1
access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1 access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1
access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1 access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_fusion_assessment_portal fusion.assessment.portal model_fusion_assessment base.group_portal 1 1 1 0
8 access_fusion_accessibility_assessment_user fusion.accessibility.assessment.user model_fusion_accessibility_assessment base.group_user 1 1 1 1
9 access_fusion_accessibility_assessment_portal fusion.accessibility.assessment.portal model_fusion_accessibility_assessment base.group_portal 1 1 1 0
access_fusion_assessment_visit_user fusion.assessment.visit.user model_fusion_assessment_visit base.group_user 1 1 1 1
access_fusion_assessment_visit_portal fusion.assessment.visit.portal model_fusion_assessment_visit base.group_portal 1 1 1 0
10 access_fusion_pdf_template_user fusion.pdf.template.user model_fusion_pdf_template base.group_user 1 1 1 1
11 access_fusion_pdf_template_preview_user fusion.pdf.template.preview.user model_fusion_pdf_template_preview base.group_user 1 1 1 1
12 access_fusion_pdf_template_field_user fusion.pdf.template.field.user model_fusion_pdf_template_field base.group_user 1 1 1 1

View File

@@ -448,42 +448,6 @@
font-size: 1.25rem; font-size: 1.25rem;
} }
/* Start a Visit Card on Portal Home (distinct blue->indigo so it differs from
the green New Assessment tile). Mirrors .portal-new-assessment-card. */
.portal-visit-card {
background: linear-gradient(135deg, #2e7aad 0%, #4338ca 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-visit-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(67, 56, 202, 0.3) !important;
}
.portal-visit-card .card-body {
background: transparent !important;
}
.portal-visit-card h5,
.portal-visit-card small {
color: #fff !important;
}
.portal-visit-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-visit-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Authorizer Portal Card on Portal Home */ /* Authorizer Portal Card on Portal Home */
.portal-authorizer-card { .portal-authorizer-card {
background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)) !important; background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)) !important;

View File

@@ -388,7 +388,6 @@
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small> <small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
</div> </div>
</div> </div>
<input type="hidden" name="visit_id" id="acc_visit_id"/>
</div> </div>
</div> </div>
</template> </template>
@@ -648,15 +647,6 @@
// Fallback if Google Maps not loaded // Fallback if Google Maps not loaded
window.initAddressAutocomplete = window.initAddressAutocomplete || function() {}; window.initAddressAutocomplete = window.initAddressAutocomplete || function() {};
// Carry visit_id from the workspace launch (?visit_id=) into the form
(function() {
var _vid = new URLSearchParams(window.location.search).get('visit_id');
if (_vid) {
var f = document.getElementById('acc_visit_id');
if (f) { f.value = _vid; }
}
})();
// Form submission // Form submission
function saveAssessment(createSaleOrder) { function saveAssessment(createSaleOrder) {
var form = document.getElementById('accessibility_form'); var form = document.getElementById('accessibility_form');

View File

@@ -85,19 +85,7 @@
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="assessment_id" t-att-value="assessment.id if assessment else ''"/> <input type="hidden" name="assessment_id" t-att-value="assessment.id if assessment else ''"/>
<input type="hidden" name="current_page" value="1"/> <input type="hidden" name="current_page" value="1"/>
<input type="hidden" name="visit_id" id="express_visit_id" t-att-value="visit_id or ''"/>
<!-- Part of an assessment visit: completing returns to the visit, which groups
this device with the rest into one funding-routed ADP order. -->
<div t-if="visit_id" class="alert alert-info d-flex align-items-start gap-2 mb-3">
<i class="fa fa-clipboard mt-1"/>
<div>
<strong>Part of an assessment visit.</strong>
Completing this device returns you to the visit — it is grouped with the
visit's other ADP devices into a single sale order when you complete the visit.
</div>
</div>
<!-- Equipment Selection Section --> <!-- Equipment Selection Section -->
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
@@ -110,7 +98,6 @@
<option value="rollator" t-att-selected="assessment.equipment_type == 'rollator' if assessment else False">Rollator</option> <option value="rollator" t-att-selected="assessment.equipment_type == 'rollator' if assessment else False">Rollator</option>
<option value="wheelchair" t-att-selected="assessment.equipment_type == 'wheelchair' if assessment else False">Wheelchair</option> <option value="wheelchair" t-att-selected="assessment.equipment_type == 'wheelchair' if assessment else False">Wheelchair</option>
<option value="powerchair" t-att-selected="assessment.equipment_type == 'powerchair' if assessment else False">Powerchair</option> <option value="powerchair" t-att-selected="assessment.equipment_type == 'powerchair' if assessment else False">Powerchair</option>
<option value="scooter" t-att-selected="assessment.equipment_type == 'scooter' if assessment else False">Mobility Scooter</option>
</select> </select>
</div> </div>
@@ -701,62 +688,8 @@
<label class="form-label fw-bold">Additional Information/Customization</label> <label class="form-label fw-bold">Additional Information/Customization</label>
<textarea name="additional_customization" class="form-control powerchair-field" rows="4" placeholder="Enter any additional requirements or customization notes..."><t t-esc="assessment.additional_customization if assessment else ''"/></textarea> <textarea name="additional_customization" class="form-control powerchair-field" rows="4" placeholder="Enter any additional requirements or customization notes..."><t t-esc="assessment.additional_customization if assessment else ''"/></textarea>
</div> </div>
<!-- Power-mobility home-accessibility — ADP hard rule -->
<div class="mb-4 p-3 border rounded bg-light">
<label class="form-label fw-bold">Home accessible for the device — inside &amp; outside?</label>
<select name="x_fc_power_home_accessible" class="form-control">
<option value="">-- Select --</option>
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
</select>
<div class="alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
</div>
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
</div>
</div> </div>
<!-- ===== MOBILITY SCOOTER ===== -->
<div id="scooter_form" class="equipment-form" style="display: none;">
<h2 class="text-center fw-bold text-uppercase mb-4">Mobility Scooter Assessment</h2>
<div class="mb-4">
<label class="form-label fw-bold">Scooter Type</label>
<select name="scooter_type" class="form-select">
<option value="">-- Select Type --</option>
<option value="travel_3" t-att-selected="assessment.scooter_type == 'travel_3' if assessment else False">3-Wheel Travel/Portable</option>
<option value="travel_4" t-att-selected="assessment.scooter_type == 'travel_4' if assessment else False">4-Wheel Travel/Portable</option>
<option value="standard_3" t-att-selected="assessment.scooter_type == 'standard_3' if assessment else False">3-Wheel Standard</option>
<option value="standard_4" t-att-selected="assessment.scooter_type == 'standard_4' if assessment else False">4-Wheel Standard</option>
<option value="heavy_duty" t-att-selected="assessment.scooter_type == 'heavy_duty' if assessment else False">Heavy-Duty / Bariatric</option>
</select>
</div>
<div class="row mb-3">
<div class="col-12 col-md-6 mb-3">
<label class="form-label fw-bold">Maximum Range Needed (km)</label>
<div class="input-group">
<input type="number" step="1" name="scooter_max_range" class="form-control"
t-att-value="assessment.scooter_max_range if assessment else ''"/>
<span class="input-group-text">km</span>
</div>
</div>
</div>
<!-- Power-mobility home-accessibility — ADP hard rule -->
<div class="mb-4 p-3 border rounded bg-light">
<label class="form-label fw-bold">Home accessible for the device — inside &amp; outside?</label>
<select name="x_fc_power_home_accessible" class="form-control">
<option value="">-- Select --</option>
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
</select>
<div class="alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
</div>
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
</div>
</div>
</div> </div>
</div> </div>
@@ -1258,10 +1191,9 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body text-center"> <div class="card-body text-center">
<button type="submit" name="action" value="submit" class="btn btn-success btn-lg px-5"> <button type="submit" name="action" value="submit" class="btn btn-success btn-lg px-5">
<t t-if="visit_id"><i class="fa fa-clipboard me-2"/>Save to Visit</t> <i class="fa fa-check me-2"/>Submit Assessment
<t t-else=""><i class="fa fa-check me-2"/>Submit Assessment</t>
</button> </button>
<a t-att-href="('/my/visit/%s' % visit_id) if visit_id else '/my/assessments'" class="btn btn-outline-secondary btn-lg px-4 ms-3"> <a href="/my/assessments" class="btn btn-outline-secondary btn-lg px-4 ms-3">
Cancel Cancel
</a> </a>
</div> </div>
@@ -1346,7 +1278,6 @@
var rollatorForm = document.getElementById('rollator_form'); var rollatorForm = document.getElementById('rollator_form');
var wheelchairForm = document.getElementById('wheelchair_form'); var wheelchairForm = document.getElementById('wheelchair_form');
var powerchairForm = document.getElementById('powerchair_form'); var powerchairForm = document.getElementById('powerchair_form');
var scooterForm = document.getElementById('scooter_form');
var wheelchairTypeSelect = document.querySelector('select[name="wheelchair_type"]'); var wheelchairTypeSelect = document.querySelector('select[name="wheelchair_type"]');
var reasonSelect = document.getElementById('reason_for_application'); var reasonSelect = document.getElementById('reason_for_application');
var previousFundingContainer = document.getElementById('previous_funding_date_container'); var previousFundingContainer = document.getElementById('previous_funding_date_container');
@@ -1408,16 +1339,13 @@
disableFormInputs(rollatorForm); disableFormInputs(rollatorForm);
disableFormInputs(wheelchairForm); disableFormInputs(wheelchairForm);
disableFormInputs(powerchairForm); disableFormInputs(powerchairForm);
disableFormInputs(scooterForm);
if (value === 'rollator') { if (value === 'rollator') {
enableFormInputs(rollatorForm); enableFormInputs(rollatorForm);
} else if (value === 'wheelchair') { } else if (value === 'wheelchair') {
enableFormInputs(wheelchairForm); enableFormInputs(wheelchairForm);
} else if (value === 'powerchair') { } else if (value === 'powerchair') {
enableFormInputs(powerchairForm); enableFormInputs(powerchairForm);
} else if (value === 'scooter') {
enableFormInputs(scooterForm);
} }
} }

View File

@@ -50,25 +50,6 @@
<!-- Main Action Tiles - Professional Grid Layout --> <!-- Main Action Tiles - Professional Grid Layout -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<!-- 0. Start a Visit (Sales Rep) - bundle multiple assessments from one home visit -->
<t t-if="request.env.user.partner_id.is_sales_rep_portal">
<div class="col-md-6">
<a href="/my/visit/new" class="card h-100 border-0 shadow-sm text-decoration-none portal-visit-card" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="icon-circle">
<i class="fa fa-clipboard"/>
</div>
</div>
<div>
<h5 class="mb-1">Start a Visit</h5>
<small>Bundle several assessments from one home visit into funding-routed orders</small>
</div>
</div>
</a>
</div>
</t>
<!-- 1. New Express Assessment (Sales Rep) - Featured tile --> <!-- 1. New Express Assessment (Sales Rep) - Featured tile -->
<t t-if="request.env.user.partner_id.is_sales_rep_portal"> <t t-if="request.env.user.partner_id.is_sales_rep_portal">
<div class="col-md-6"> <div class="col-md-6">

View File

@@ -1,115 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="portal_visit_workspace" name="Assessment Visit Workspace">
<t t-call="portal.portal_layout">
<t t-set="no_breadcrumbs" t-value="True"/>
<div class="container py-4">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/my">Dashboard</a></li>
<li class="breadcrumb-item active">Assessment Visit</li>
</ol>
</nav>
<t t-if="error">
<div class="alert alert-danger"><i class="fa fa-exclamation-circle"/> <t t-esc="error"/></div>
</t>
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0"><i class="fa fa-clipboard text-primary"/> Visit <t t-esc="visit.name"/></h3>
<span class="badge bg-secondary"><span t-field="visit.state"/></span>
</div>
<t t-if="visit.state == 'done'">
<div class="alert alert-success">
<strong>Visit completed.</strong> Sale orders created:
<ul class="mb-0">
<t t-foreach="visit.sale_order_ids" t-as="so">
<li><a t-attf-href="/my/sales/case/{{so.id}}"><t t-esc="so.name"/></a><span t-field="so.x_fc_sale_type"/></li>
</t>
</ul>
</div>
</t>
<!-- Assessments added this visit -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>Assessments this visit</strong> (<t t-esc="visit.assessment_count"/>)</div>
<div class="card-body">
<p t-if="not visit.assessment_count" class="text-muted mb-0">
Nothing added yet — use the buttons below to add what you're assessing.
</p>
<ul class="list-group" t-if="visit.assessment_count">
<t t-foreach="visit.adp_assessment_ids" t-as="a">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="fa fa-wheelchair text-primary"/> ADP — <span t-field="a.equipment_type"/></span>
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
</li>
</t>
<t t-foreach="visit.accessibility_assessment_ids" t-as="a">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><span t-field="a.assessment_type"/><span t-field="a.x_fc_funding_source"/></span>
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
</li>
</t>
</ul>
</div>
</div>
<t t-if="visit.state != 'done'">
<!-- Add assessment -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>+ Add assessment</strong></div>
<div class="card-body d-flex flex-wrap gap-2">
<a class="btn btn-outline-primary" t-attf-href="/my/assessment/express?visit_id={{visit.id}}">Wheelchair / ADP</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/straight?visit_id={{visit.id}}">Straight Stair Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/curved?visit_id={{visit.id}}">Curved Stair Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/vpl?visit_id={{visit.id}}">Platform / Porch Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ceiling-lift?visit_id={{visit.id}}">Ceiling Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ramp?visit_id={{visit.id}}">Custom Ramp</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/bathroom?visit_id={{visit.id}}">Bathroom Mod</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/tub-cutout?visit_id={{visit.id}}">Tub Cutout</a>
</div>
</div>
<!-- Client details (deferred) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>Client details</strong>
<span class="text-muted small">— fill in after the therapist leaves</span></div>
<div class="card-body">
<form t-attf-action="/my/visit/{{visit.id}}/save" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">Client Name</label>
<input type="text" name="client_name" class="form-control" t-att-value="visit.client_name"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Phone</label>
<input type="text" name="client_phone" class="form-control" t-att-value="visit.client_phone"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Email</label>
<input type="email" name="client_email" class="form-control" t-att-value="visit.client_email"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Address</label>
<input type="text" name="client_address" class="form-control" t-att-value="visit.client_address"/></div>
</div>
<div class="mb-3" t-if="visit.has_mod_items">
<label class="form-label">Income under March of Dimes threshold?
<span class="text-muted small">(MOD covers up to $15k/person, lifetime)</span></label>
<select name="x_fc_income_under_mod_threshold" class="form-select">
<option value="unknown" t-att-selected="visit.x_fc_income_under_mod_threshold == 'unknown'">Unknown</option>
<option value="yes" t-att-selected="visit.x_fc_income_under_mod_threshold == 'yes'">Yes — under threshold (full $15k)</option>
<option value="no" t-att-selected="visit.x_fc_income_under_mod_threshold == 'no'">No — over threshold (may be denied/partial)</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">Save client details</button>
</form>
</div>
</div>
<!-- Complete -->
<form t-attf-action="/my/visit/{{visit.id}}/complete" method="post" t-if="visit.assessment_count">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<button type="submit" class="btn btn-primary btn-lg">Complete visit &amp; create sale orders &#8594;</button>
<p class="text-muted small mt-2">Creates one sale order per funding workflow (ADP / March of Dimes / private / ...).</p>
</form>
</t>
</div>
</t>
</template>
</odoo>

View File

@@ -1,108 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form -->
<record id="view_fusion_assessment_visit_form" model="ir.ui.view">
<field name="name">fusion.assessment.visit.form</field>
<field name="model">fusion.assessment.visit</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_complete_visit" type="object"
string="Complete Visit &amp; Create Sale Orders"
class="btn-primary" invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="measuring,client_pending,done"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Client">
<field name="partner_id"/>
<field name="client_name"/>
<field name="client_phone"/>
<field name="client_email"/>
</group>
<group string="Visit">
<field name="visit_date"/>
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="has_mod_items" invisible="1"/>
<field name="x_fc_income_under_mod_threshold"
invisible="not has_mod_items"/>
</group>
</group>
<div class="alert alert-info" role="alert" invisible="not has_mod_items">
<strong>March of Dimes:</strong> covers up to $15,000 per person
(lifetime), income-gated. Confirm the client's income status above.
</div>
<notebook>
<page string="Accessibility Assessments">
<field name="accessibility_assessment_ids" readonly="1">
<list>
<field name="reference"/>
<field name="assessment_type"/>
<field name="x_fc_funding_source"/>
<field name="state"/>
<field name="sale_order_id"/>
</list>
</field>
</page>
<page string="ADP Assessments">
<field name="adp_assessment_ids" readonly="1">
<list>
<field name="equipment_type"/>
<field name="state"/>
<field name="sale_order_id"/>
</list>
</field>
</page>
<page string="Sale Orders">
<field name="sale_order_ids" readonly="1">
<list>
<field name="name"/>
<field name="x_fc_sale_type"/>
<field name="state"/>
<field name="amount_total"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- List -->
<record id="view_fusion_assessment_visit_list" model="ir.ui.view">
<field name="name">fusion.assessment.visit.list</field>
<field name="model">fusion.assessment.visit</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="visit_date"/>
<field name="partner_id"/>
<field name="client_name"/>
<field name="sales_rep_id"/>
<field name="assessment_count"/>
<field name="sale_order_count"/>
<field name="state"/>
</list>
</field>
</record>
<!-- Action + menu -->
<record id="action_fusion_assessment_visit" model="ir.actions.act_window">
<field name="name">Assessment Visits</field>
<field name="res_model">fusion.assessment.visit</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_assessment_visit"
name="Assessment Visits"
parent="sale.sale_menu_root"
action="action_fusion_assessment_visit"
sequence="50"/>
</odoo>

View File

@@ -4,7 +4,7 @@
{ {
'name': 'Fusion Repairs', 'name': 'Fusion Repairs',
'version': '19.0.2.2.6', 'version': '19.0.2.3.0',
'category': 'Inventory/Repairs', 'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """ 'description': """

View File

@@ -125,8 +125,8 @@
We would love to hear how it went - your feedback helps other clients We would love to hear how it went - your feedback helps other clients
find us and helps us improve. find us and helps us improve.
</p> </p>
<!-- H4: URL-encode the company name so the fallback URL survives ampersands + spaces. --> <!-- H4: build a fallback search URL without url_encode (not available in the mail QWeb render context); replace spaces so the URL survives. -->
<t t-set="review_url" t-value="object.company_id.x_fc_google_review_url or ('https://www.google.com/search?' + url_encode({'q': object.company_id.name or ''}))"/> <t t-set="review_url" t-value="object.company_id.x_fc_google_review_url or ('https://www.google.com/search?q=' + (object.company_id.name or '').replace(' ', '+'))"/>
<div style="text-align:center;margin:0 0 24px 0;"> <div style="text-align:center;margin:0 0 24px 0;">
<a t-att-href="review_url" <a t-att-href="review_url"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;"> style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
@@ -433,6 +433,12 @@
is due for its next scheduled maintenance visit on is due for its next scheduled maintenance visit on
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>. <strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
</p> </p>
<div t-if="object.x_fc_maintenance_fee" style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
Maintenance visit fee:
<strong><t t-out="object.x_fc_maintenance_fee" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></strong><span style="opacity:0.6;"> + applicable tax</span>.
</p>
</div>
<div style="text-align:center;margin:0 0 24px 0;"> <div style="text-align:center;margin:0 0 24px 0;">
<a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}" <a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;"> style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">

View File

@@ -80,6 +80,26 @@ class FusionRepairMaintenanceContract(models.Model):
'res.company', default=lambda self: self.env.company, 'res.company', default=lambda self: self.env.company,
) )
currency_id = fields.Many2one(
'res.currency', default=lambda self: self.env.company.currency_id,
)
x_fc_maintenance_fee = fields.Monetary(
string='Maintenance Fee', currency_field='currency_id',
help='Flat fee shown to the client for this maintenance visit.',
)
x_fc_source = fields.Selection(
[('sale', 'New Sale'), ('backfill', 'Backfill'),
('claims', 'Claims Bridge'), ('manual', 'Manual')],
string='Source', default='manual', index=True,
)
x_fc_source_sale_line_id = fields.Many2one(
'sale.order.line', string='Source Sale Line', index=True, copy=False,
)
x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False)
x_fc_policy_category_id = fields.Many2one(
'fusion.repair.product.category', string='Maintenance Policy',
)
_booking_token_unique = models.Constraint( _booking_token_unique = models.Constraint(
'unique(booking_token)', 'unique(booking_token)',
'Booking token must be unique.', 'Booking token must be unique.',
@@ -195,11 +215,18 @@ class FusionRepairMaintenanceContract(models.Model):
class SaleOrder(models.Model): class SaleOrder(models.Model):
_inherit = 'sale.order' _inherit = 'sale.order'
def _fc_maintenance_anchor_date(self, line):
"""Best-available delivery anchor: commitment_date -> date_order -> today.
(Non-ADP / lift units lack a delivery date; this fallback chain handles them.)"""
so = line.order_id
anchor = so.commitment_date or so.date_order
return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self)
def _spawn_maintenance_contracts(self): def _spawn_maintenance_contracts(self):
"""Create maintenance contracts for any delivered SO line whose """Create a priced maintenance contract per maintainable unit on a confirmed SO.
product has x_fc_maintenance_interval_months > 0.""" Policy = product interval override, else the product's category policy.
Idempotent: by serial when captured, else by source sale line."""
Contract = self.env['fusion.repair.maintenance.contract'].sudo() Contract = self.env['fusion.repair.maintenance.contract'].sudo()
today = fields.Date.context_today(self)
for so in self: for so in self:
if so.state not in ('sale', 'done'): if so.state not in ('sale', 'done'):
continue continue
@@ -207,21 +234,42 @@ class SaleOrder(models.Model):
product = line.product_id product = line.product_id
if not product: if not product:
continue continue
interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0 tmpl = product.product_tmpl_id
if interval <= 0: category = tmpl.x_fc_repair_category_id
product_interval = tmpl.x_fc_maintenance_interval_months or 0
cat_enabled = bool(category) and category.x_fc_maintenance_enabled
interval = product_interval or (
category.x_fc_maintenance_interval_months if cat_enabled else 0)
if interval <= 0 or not (product_interval > 0 or cat_enabled):
continue continue
existing = Contract.search([ fee = tmpl.x_fc_maintenance_fee or (
('partner_id', '=', so.partner_id.id), category.x_fc_maintenance_fee if category else 0.0)
('product_id', '=', product.id), # Capture serial only if fusion_claims' line field is present.
('original_sale_order_id', '=', so.id), serial = ''
], limit=1) if 'x_fc_serial_number' in line._fields:
if existing: serial = (line.x_fc_serial_number or '').strip()
# Idempotency: serial regime vs source-line regime (spec 6.2).
if serial:
dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)]
else:
dedup = [('state', '=', 'active'),
('x_fc_source_sale_line_id', '=', line.id)]
if Contract.search_count(dedup):
continue continue
Contract.create({ anchor = so._fc_maintenance_anchor_date(line)
'partner_id': so.partner_id.id, # One contract per serialized unit; without a serial, per quantity.
'product_id': product.id, count = 1 if serial else max(int(line.product_uom_qty or 1), 1)
'original_sale_order_id': so.id, for _i in range(count):
'interval_months': interval, Contract.create({
'next_due_date': today + relativedelta(months=interval), 'partner_id': so.partner_id.id,
'state': 'active', 'product_id': product.id,
}) 'original_sale_order_id': so.id,
'x_fc_source_sale_line_id': line.id,
'x_fc_source': 'sale',
'x_fc_device_serial': serial,
'x_fc_policy_category_id': category.id if category else False,
'interval_months': interval,
'x_fc_maintenance_fee': fee,
'next_due_date': anchor + relativedelta(months=interval),
'state': 'active',
})

View File

@@ -26,6 +26,10 @@ class ProductTemplate(models.Model):
help='If > 0, delivering a unit of this product auto-creates a maintenance contract ' help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
'with this recurring interval. Phase 3 feature.', 'with this recurring interval. Phase 3 feature.',
) )
x_fc_maintenance_fee = fields.Monetary(
string='Maintenance Fee (override)', currency_field='currency_id',
help='Per-product override of the category maintenance fee. 0 = use the category fee.',
)
x_fc_intake_template_id = fields.Many2one( x_fc_intake_template_id = fields.Many2one(
'fusion.repair.intake.template', 'fusion.repair.intake.template',
string='Intake Template Override', string='Intake Template Override',

View File

@@ -53,6 +53,31 @@ class FusionRepairProductCategory(models.Model):
help='Default intake question set shown when this category is selected.', help='Default intake question set shown when this category is selected.',
) )
# ── Maintenance policy (per equipment type) ──────────────────────────
x_fc_maintenance_enabled = fields.Boolean(
string='Offer Maintenance',
help='If set, units in this category are enrolled in recurring preventive '
'maintenance on sale (and via the backfill wizard).',
)
x_fc_maintenance_interval_months = fields.Integer(
string='Maintenance Interval (Months)', default=6,
help='Default months between preventive maintenance visits for this category. '
'Overridden by the product field of the same name when that is > 0.',
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
x_fc_maintenance_fee = fields.Monetary(
string='Maintenance Fee', currency_field='currency_id',
help='Flat fee shown to the client for a maintenance visit of this equipment type.',
)
x_fc_maintenance_service_product_id = fields.Many2one(
'product.product', string='Maintenance Service Product',
help='Optional product used when drafting the priced visit line (Plan 2). '
'Falls back to a generic visit product.',
)
_code_unique = models.Constraint( _code_unique = models.Constraint(
'unique(code)', 'unique(code)',
'Category code must be unique.', 'Category code must be unique.',

View File

@@ -247,6 +247,7 @@ class SaleOrder(models.Model):
# Bundle 9: spawn store labor warranties for any product line with # Bundle 9: spawn store labor warranties for any product line with
# x_fc_labor_warranty_years > 0. # x_fc_labor_warranty_years > 0.
self._fc_spawn_labor_warranties() self._fc_spawn_labor_warranties()
self._spawn_maintenance_contracts()
return res return res
def _fc_spawn_labor_warranties(self): def _fc_spawn_labor_warranties(self):

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_maintenance_foundation

View File

@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestMaintenanceFoundation(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Mrs. Test Client'})
cls.category = cls.env['fusion.repair.product.category'].create({
'name': 'Stair Lift', 'code': 'stairlift_test',
'equipment_class': 'lift_elevating', 'safety_critical': True,
'x_fc_maintenance_enabled': True,
'x_fc_maintenance_interval_months': 6,
'x_fc_maintenance_fee': 149.0,
})
# ---- Tasks 1/2/3: fields exist ----
def test_category_policy_fields(self):
self.assertTrue(self.category.x_fc_maintenance_enabled)
self.assertEqual(self.category.x_fc_maintenance_interval_months, 6)
self.assertEqual(self.category.x_fc_maintenance_fee, 149.0)
self.assertTrue(self.category.currency_id)
def test_product_fee_override_field(self):
tmpl = self.env['product.template'].create({
'name': 'Handicare Freecurve Stairlift',
'x_fc_repair_category_id': self.category.id,
'x_fc_maintenance_fee': 199.0,
})
self.assertEqual(tmpl.x_fc_maintenance_fee, 199.0)
def test_contract_extension_fields(self):
product = self.env['product.product'].create({'name': 'Unit'})
c = self.env['fusion.repair.maintenance.contract'].create({
'partner_id': self.partner.id,
'product_id': product.id,
'next_due_date': '2026-12-01',
'x_fc_source': 'sale',
'x_fc_device_serial': 'SN-123',
'x_fc_maintenance_fee': 149.0,
})
self.assertEqual(c.x_fc_source, 'sale')
self.assertEqual(c.x_fc_device_serial, 'SN-123')
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
# ---- Task 4: spawn priced contracts on sale confirm ----
def _make_product(self, **kw):
vals = {'name': 'Stairlift Unit', 'type': 'consu',
'x_fc_repair_category_id': self.category.id}
vals.update(kw)
return self.env['product.product'].create(vals)
def _confirm_so(self, product, commitment='2026-01-10'):
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'commitment_date': commitment,
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
})
so.action_confirm()
return so
def _contracts_for(self, so):
return self.env['fusion.repair.maintenance.contract'].search(
[('original_sale_order_id', '=', so.id)])
def test_no_contract_when_category_not_maintainable(self):
cat = self.env['fusion.repair.product.category'].create(
{'name': 'Cane', 'code': 'cane_test', 'x_fc_maintenance_enabled': False})
so = self._confirm_so(self._make_product(x_fc_repair_category_id=cat.id))
self.assertFalse(self._contracts_for(so))
def test_contract_created_via_category_policy(self):
so = self._confirm_so(self._make_product())
contracts = self._contracts_for(so)
self.assertEqual(len(contracts), 1)
c = contracts
self.assertEqual(c.interval_months, 6)
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
self.assertEqual(c.x_fc_source, 'sale')
self.assertEqual(c.x_fc_policy_category_id, self.category)
# anchor = commitment_date (2026-01-10) + 6 months
self.assertEqual(str(c.next_due_date), '2026-07-10')
def test_product_override_beats_category(self):
p = self._make_product()
p.product_tmpl_id.x_fc_maintenance_interval_months = 3
p.product_tmpl_id.x_fc_maintenance_fee = 199.0
so = self._confirm_so(p)
c = self._contracts_for(so)
self.assertEqual(c.interval_months, 3)
self.assertEqual(c.x_fc_maintenance_fee, 199.0)
def test_idempotent_on_reconfirm(self):
p = self._make_product()
so = self._confirm_so(p)
so._spawn_maintenance_contracts() # call again -> no duplicate
self.assertEqual(len(self._contracts_for(so)), 1)

View File

@@ -57,6 +57,13 @@
action="action_repair_part_order" action="action_repair_part_order"
sequence="38"/> sequence="38"/>
<!-- Configuration parent: must be defined before the children that reference it below -->
<menuitem id="menu_fusion_repairs_configuration"
name="Configuration"
parent="menu_fusion_repairs_root"
sequence="90"
groups="fusion_repairs.group_fusion_repairs_manager"/>
<menuitem id="menu_fusion_repairs_emergency_charges" <menuitem id="menu_fusion_repairs_emergency_charges"
name="Emergency Surcharges" name="Emergency Surcharges"
parent="menu_fusion_repairs_configuration" parent="menu_fusion_repairs_configuration"
@@ -100,13 +107,6 @@
action="action_repair_labor_warranty" action="action_repair_labor_warranty"
sequence="36"/> sequence="36"/>
<!-- Configuration -->
<menuitem id="menu_fusion_repairs_configuration"
name="Configuration"
parent="menu_fusion_repairs_root"
sequence="90"
groups="fusion_repairs.group_fusion_repairs_manager"/>
<menuitem id="menu_fusion_repairs_categories" <menuitem id="menu_fusion_repairs_categories"
name="Equipment Categories" name="Equipment Categories"
parent="menu_fusion_repairs_configuration" parent="menu_fusion_repairs_configuration"

View File

@@ -40,6 +40,13 @@
<field name="active"/> <field name="active"/>
</group> </group>
</group> </group>
<group string="Maintenance Policy">
<field name="x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_interval_months" invisible="not x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_fee" invisible="not x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_service_product_id" invisible="not x_fc_maintenance_enabled"/>
<field name="currency_id" invisible="1"/>
</group>
<field name="description" placeholder="Describe what equipment falls into this category..."/> <field name="description" placeholder="Describe what equipment falls into this category..."/>
</sheet> </sheet>
</form> </form>