Compare commits
8 Commits
feat/fusio
...
40a29081bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40a29081bf | ||
|
|
859a327738 | ||
|
|
9a8e1d7ab5 | ||
|
|
7fcf38ca82 | ||
|
|
64a202ff6e | ||
|
|
13fabb0e79 | ||
|
|
319de06ca6 | ||
|
|
0499a1ad2e |
@@ -630,8 +630,27 @@ 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) from `work_centre.area_kind` with a fallback to a step-kind
|
(stored) in `_compute_area_kind` (`fusion_plating_jobs/models/fp_job_step.py`):
|
||||||
dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
|
`work_centre.area_kind` → else `recipe_node.kind_id.area_kind` (the
|
||||||
|
`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,
|
||||||
@@ -1847,9 +1866,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.
|
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.
|
||||||
|
|
||||||
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.
|
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).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -1861,6 +1880,12 @@ 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. **`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.
|
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:**
|
||||||
|
- **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.
|
||||||
|
|||||||
@@ -157,33 +157,71 @@ 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:
|
Priority chain (non-gating steps):
|
||||||
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)
|
||||||
|
|
||||||
The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind
|
Gating/marker steps (kind `code == 'gating'` — the "Ready for X"
|
||||||
now self-declares its area_kind, so the kind taxonomy IS the
|
steps) have NO physical location; the taxonomy maps them to
|
||||||
source of truth. See spec
|
'receiving', which made a mid-recipe gate snap the job's card back
|
||||||
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
to the first column (Racking -> "Ready for processing" jumped to
|
||||||
|
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:
|
||||||
# 1. Explicit work_centre wins
|
step.area_kind = step._fp_resolve_area_kind()
|
||||||
if step.work_centre_id and step.work_centre_id.area_kind:
|
|
||||||
step.area_kind = step.work_centre_id.area_kind
|
def _fp_raw_area_kind(self):
|
||||||
continue
|
"""Area from this step's OWN work_centre / kind only — no look-ahead
|
||||||
# 2. Kind taxonomy
|
and no dependence on the computed `area_kind` field (so the gating
|
||||||
node = step.recipe_node_id
|
fall-forward below can't recurse)."""
|
||||||
if node and node.kind_id and node.kind_id.area_kind:
|
self.ensure_one()
|
||||||
step.area_kind = node.kind_id.area_kind
|
if self.work_centre_id and self.work_centre_id.area_kind:
|
||||||
continue
|
return self.work_centre_id.area_kind
|
||||||
# 3. Catch-all — only reached for orphaned steps (no
|
node = self.recipe_node_id
|
||||||
# work_centre AND no recipe_node).
|
if node and node.kind_id and node.kind_id.area_kind:
|
||||||
step.area_kind = 'plating'
|
return node.kind_id.area_kind
|
||||||
|
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',
|
||||||
@@ -698,19 +736,29 @@ 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").
|
||||||
|
|
||||||
Only fires for steps that had REAL incoming parts — never an
|
Fires for any step that actually moved parts OUT and drained to
|
||||||
untouched first-step seed. Returns True if the step finished.
|
zero — INCLUDING the first/seeded stage (its qty comes from the
|
||||||
|
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
|
||||||
|
|||||||
@@ -124,8 +124,18 @@ 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: s.sequence < to_step.sequence
|
lambda s: from_step.sequence < 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:
|
||||||
|
|||||||
@@ -806,20 +806,25 @@ $_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;
|
||||||
color: var(--bs-body-color);
|
background-color: rgba(245, 158, 11, 0.16);
|
||||||
// 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 {
|
||||||
color: var(--bs-secondary-color, #333);
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_finish_block_list {
|
.o_fp_finish_block_list {
|
||||||
@@ -834,9 +839,9 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_finish_block_action_note {
|
.o_fp_finish_block_action_note {
|
||||||
color: var(--bs-secondary-color, #555);
|
// Inherit text colour; translucent neutral box works in both themes.
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: 0.6rem 0.8rem;
|
padding: 0.6rem 0.8rem;
|
||||||
background: var(--bs-tertiary-bg, #f3f4f6);
|
background: rgba(128, 128, 128, 0.12);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': 'Fusion Authorizer & Sales Portal',
|
'name': 'Fusion Authorizer & Sales Portal',
|
||||||
'version': '19.0.2.8.0',
|
'version': '19.0.2.10.1',
|
||||||
'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,12 +64,14 @@ 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',
|
||||||
@@ -79,6 +81,7 @@ 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': [
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +517,7 @@ 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,
|
||||||
@@ -630,6 +632,30 @@ 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
|
||||||
@@ -804,6 +830,13 @@ class AssessmentPortal(CustomerPortal):
|
|||||||
"""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'):
|
||||||
vals['equipment_type'] = kw.get('equipment_type')
|
vals['equipment_type'] = kw.get('equipment_type')
|
||||||
@@ -815,6 +848,14 @@ 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 = [
|
||||||
|
|||||||
@@ -2479,6 +2479,56 @@ 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"""
|
||||||
@@ -2547,6 +2597,13 @@ 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}")
|
||||||
@@ -2572,8 +2629,12 @@ 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 {
|
||||||
@@ -2586,12 +2647,13 @@ 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 as draft.',
|
'message': f'Assessment {assessment.reference} saved.',
|
||||||
'redirect_url': '/my/accessibility/list',
|
'redirect_url': redirect_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
13
fusion_portal/data/visit_data.xml
Normal file
13
fusion_portal/data/visit_data.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?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>
|
||||||
@@ -7,5 +7,6 @@ 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
|
||||||
@@ -157,6 +157,13 @@ 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(
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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,6 +71,31 @@ class FusionAssessment(models.Model):
|
|||||||
('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))
|
||||||
rollator_seat_height = fields.Float(string='Seat Height (inches)', digits=(10, 2))
|
rollator_seat_height = fields.Float(string='Seat Height (inches)', digits=(10, 2))
|
||||||
@@ -425,6 +451,14 @@ 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(
|
||||||
@@ -1452,20 +1486,18 @@ class FusionAssessment(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _send_completion_notifications(self):
|
def _send_completion_notifications(self):
|
||||||
"""Send email notifications when assessment is completed"""
|
"""Notify the CLIENT that the assessment is complete.
|
||||||
|
|
||||||
|
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 authorizer
|
# Send to client (authorizer/rep/office already emailed by
|
||||||
if self.authorizer_id and self.authorizer_id.email:
|
# _send_assessment_completed_email in _create_draft_sale_order)
|
||||||
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)
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ class SaleOrder(models.Model):
|
|||||||
'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(
|
||||||
'res.partner',
|
'res.partner',
|
||||||
|
|||||||
299
fusion_portal/models/visit.py
Normal file
299
fusion_portal/models/visit.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# -*- 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},
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ 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
|
||||||
|
@@ -448,6 +448,42 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -388,6 +388,7 @@
|
|||||||
<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>
|
||||||
@@ -647,6 +648,15 @@
|
|||||||
// 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');
|
||||||
|
|||||||
@@ -85,6 +85,18 @@
|
|||||||
<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">
|
||||||
@@ -98,6 +110,7 @@
|
|||||||
<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>
|
||||||
|
|
||||||
@@ -688,6 +701,60 @@
|
|||||||
<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 & 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>
|
||||||
|
|
||||||
|
<!-- ===== 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 & 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>
|
||||||
@@ -1191,9 +1258,10 @@
|
|||||||
<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">
|
||||||
<i class="fa fa-check me-2"/>Submit Assessment
|
<t t-if="visit_id"><i class="fa fa-clipboard me-2"/>Save to Visit</t>
|
||||||
|
<t t-else=""><i class="fa fa-check me-2"/>Submit Assessment</t>
|
||||||
</button>
|
</button>
|
||||||
<a href="/my/assessments" class="btn btn-outline-secondary btn-lg px-4 ms-3">
|
<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">
|
||||||
Cancel
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -1278,6 +1346,7 @@
|
|||||||
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');
|
||||||
@@ -1339,6 +1408,7 @@
|
|||||||
disableFormInputs(rollatorForm);
|
disableFormInputs(rollatorForm);
|
||||||
disableFormInputs(wheelchairForm);
|
disableFormInputs(wheelchairForm);
|
||||||
disableFormInputs(powerchairForm);
|
disableFormInputs(powerchairForm);
|
||||||
|
disableFormInputs(scooterForm);
|
||||||
|
|
||||||
if (value === 'rollator') {
|
if (value === 'rollator') {
|
||||||
enableFormInputs(rollatorForm);
|
enableFormInputs(rollatorForm);
|
||||||
@@ -1346,6 +1416,8 @@
|
|||||||
enableFormInputs(wheelchairForm);
|
enableFormInputs(wheelchairForm);
|
||||||
} else if (value === 'powerchair') {
|
} else if (value === 'powerchair') {
|
||||||
enableFormInputs(powerchairForm);
|
enableFormInputs(powerchairForm);
|
||||||
|
} else if (value === 'scooter') {
|
||||||
|
enableFormInputs(scooterForm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,25 @@
|
|||||||
|
|
||||||
<!-- 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">
|
||||||
|
|||||||
115
fusion_portal/views/portal_visit.xml
Normal file
115
fusion_portal/views/portal_visit.xml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?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 & create sale orders →</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>
|
||||||
108
fusion_portal/views/visit_views.xml
Normal file
108
fusion_portal/views/visit_views.xml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?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 & 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>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Repairs',
|
'name': 'Fusion Repairs',
|
||||||
'version': '19.0.2.3.0',
|
'version': '19.0.2.2.6',
|
||||||
'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': """
|
||||||
|
|||||||
@@ -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: build a fallback search URL without url_encode (not available in the mail QWeb render context); replace spaces so the URL survives. -->
|
<!-- H4: URL-encode the company name so the fallback URL survives ampersands + spaces. -->
|
||||||
<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(' ', '+'))"/>
|
<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 ''}))"/>
|
||||||
<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,12 +433,6 @@
|
|||||||
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;">
|
||||||
|
|||||||
@@ -80,26 +80,6 @@ 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.',
|
||||||
@@ -215,18 +195,11 @@ 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 a priced maintenance contract per maintainable unit on a confirmed SO.
|
"""Create maintenance contracts for any delivered SO line whose
|
||||||
Policy = product interval override, else the product's category policy.
|
product has x_fc_maintenance_interval_months > 0."""
|
||||||
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
|
||||||
@@ -234,42 +207,21 @@ class SaleOrder(models.Model):
|
|||||||
product = line.product_id
|
product = line.product_id
|
||||||
if not product:
|
if not product:
|
||||||
continue
|
continue
|
||||||
tmpl = product.product_tmpl_id
|
interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0
|
||||||
category = tmpl.x_fc_repair_category_id
|
if interval <= 0:
|
||||||
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
|
||||||
fee = tmpl.x_fc_maintenance_fee or (
|
existing = Contract.search([
|
||||||
category.x_fc_maintenance_fee if category else 0.0)
|
('partner_id', '=', so.partner_id.id),
|
||||||
# Capture serial only if fusion_claims' line field is present.
|
('product_id', '=', product.id),
|
||||||
serial = ''
|
('original_sale_order_id', '=', so.id),
|
||||||
if 'x_fc_serial_number' in line._fields:
|
], limit=1)
|
||||||
serial = (line.x_fc_serial_number or '').strip()
|
if existing:
|
||||||
# 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
|
||||||
anchor = so._fc_maintenance_anchor_date(line)
|
Contract.create({
|
||||||
# One contract per serialized unit; without a serial, per quantity.
|
'partner_id': so.partner_id.id,
|
||||||
count = 1 if serial else max(int(line.product_uom_qty or 1), 1)
|
'product_id': product.id,
|
||||||
for _i in range(count):
|
'original_sale_order_id': so.id,
|
||||||
Contract.create({
|
'interval_months': interval,
|
||||||
'partner_id': so.partner_id.id,
|
'next_due_date': today + relativedelta(months=interval),
|
||||||
'product_id': product.id,
|
'state': 'active',
|
||||||
'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',
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ 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',
|
||||||
|
|||||||
@@ -53,31 +53,6 @@ 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.',
|
||||||
|
|||||||
@@ -247,7 +247,6 @@ 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):
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
from . import test_maintenance_foundation
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# -*- 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)
|
|
||||||
@@ -57,13 +57,6 @@
|
|||||||
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"
|
||||||
@@ -107,6 +100,13 @@
|
|||||||
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"
|
||||||
|
|||||||
@@ -40,13 +40,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user