Compare commits

...

16 Commits

Author SHA1 Message Date
gsinghpal
319de06ca6 fix(fusion_plating_shopfloor): finish-dialog text readable in dark mode (real fix)
Root cause (verified against the live compiled bundle): Odoo's backend
CSS never DEFINES --bs-body-color / --bs-secondary-color / --bs-*-bg as
custom properties (0 definitions; they're only referenced). So every
color: var(--bs-body-color, #1d1d1f) — and the earlier --bs-secondary-color
swap — resolved to the dark hex fallback in BOTH light and dark mode.
That's why the prior swaps never worked. Backend dark mode here is runtime
[data-bs-theme=dark] + SCSS literals, not those vars.

Fix: the finish-block dialog text now INHERITS the modal's theme-correct
colour (same as the readable title + "Count the Parts" list items) — the
broken line was the only one setting an explicit var() colour. Tinted
banners use translucent rgba() instead of color-mix-with-undefined-var.
Verified in the served bundle: o_fp_finish_block_msg{font-weight:500;}
(no colour override).

CLAUDE.md dark-mode guidance corrected (it had wrongly recommended those
undefined vars).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:31:09 -04:00
gsinghpal
0499a1ad2e fix(fusion_plating_shopfloor): finish-dialog message readable in dark mode
The "N required input(s) haven't been recorded yet" line still read as
dark/dim in dark mode after the --text-secondary→--bs-secondary-color
swap, because --bs-secondary-color is muted/low-opacity. That line is
primary instruction text, so use the full-contrast var(--bs-body-color)
instead (+ font-weight 500). Reserve --bs-secondary-color for genuinely
secondary text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:11:42 -04:00
gsinghpal
4f48bab6e9 feat(fusion_portal): funding-source selector on accessibility forms (#3)
* feat(fusion_portal): funding-source selector on accessibility forms

Reps can now mark an accessibility assessment's funding source on the web form
(Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) so the
generated draft sale order routes to the correct funding pipeline instead of
always defaulting to private pay. Adds Hardship to the x_fc_funding_source
selection + sale_type_map; the new form <select> is auto-serialised by the
existing FormData submit, and accessibility_assessment_save now maps
funding_source -> x_fc_funding_source. The model + SO routing were already in
place (2026-04 audit fix) — this closes the form + controller gap.

Plan: docs/superpowers/plans/2026-06-02-accessibility-funding-selector.md
Spec: docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(fusion_portal): validate funding_source in accessibility save (parity with booking)

Coerce an unexpected/tampered funding_source to direct_private instead of passing
it raw into create() (which would raise on the Selection field). Mirrors the
/book-assessment controller; the whitelist is derived from the model selection so
it auto-covers hardship and any future values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:44:19 -04:00
gsinghpal
b616375679 Merge fusion_maintenance brainstorm, design spec & Plan 1 into main
Docs only: the fusion_maintenance brief (+ Westin Step 0 / install-base sizing), the approved design spec (build into fusion_repairs; flat-fee per type; new-sale trigger + two-regime backfill; technician-aware booking on fusion_tasks), and Plan 1 (Foundation) + Plans 2-5 roadmap. Implementation pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:33:50 -04:00
gsinghpal
5c4a26b65f fix(fusion_plating_shopfloor): dark-mode text/background readability
Operators saw dark-on-dark (invisible) text in the workspace + "Cannot
Finish Step" dialog in Odoo dark mode.

Root cause: var(--text-secondary, #xxx) — a made-up variable that doesn't
exist in Odoo, so it always fell back to the hardcoded dark hex (invisible
on dark). Used 33× across job_workspace.scss + 5 component stylesheets.
Replaced with the real dark-aware var(--bs-secondary-color).

Also fixed paired backgrounds that would hide the now-theme-flipped text:
- finish-block action note → var(--bs-tertiary-bg) (was #f3f4f6).
- Tinted status banners (finish-block step, overtime timer, receiving
  status) → color-mix over var(--bs-body-bg) + var(--bs-body-color).
  Odoo's bootstrap lacks the BS5.3 -bg-subtle/-text-emphasis vars
  (verified against _root.scss), so color-mix is the dark-aware path.

Solid accent pills/dots (white text) and the color-coded plant-card chips
(light-bg + dark-text, readable in both) intentionally left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:30:51 -04:00
gsinghpal
b59ad6b21e style(fusion_plating_shopfloor): polish the scan button pair
Matched, intentional look for the two scan controls:
- Scan QR (camera, primary sticker-scan) — accent-filled blue, fa-qrcode.
- Enter Code (manual / scanner-gun) — accent-tinted secondary, fa-keyboard-o.
Both now use Font Awesome icons (no emoji), inline-flex aligned icon+label.
Enter Code's class restructured so scan-alt persists alongside the active
state when the drawer is open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:30:51 -04:00
gsinghpal
8a1a09b150 fix(fusion_plating_shopfloor): scan buttons — single icon + clearer labels
The QrScanner component renders its own fa-qrcode icon; the board passed
it label '📷 Camera', so the camera button showed two icons (QR + camera
emoji). Drop the emoji → one icon.

Also clarify the two scan paths (they do different things):
- "Scan QR"  = camera scan of the printed job sticker (primary path)
- "Enter Code" = manual / hardware scanner-gun text drawer (no camera)
Reordered so the camera (sticker) scan reads first. Other QrScanner call
sites already pass plain/no labels — this was the only double-icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:30:51 -04:00
gsinghpal
a092c385ea fix(fusion_plating_shopfloor): job appearing in every not-yet-started stage
Regression from the partial-order board: _job_presences emitted a card
for any area containing a `ready` step. These recipes seed ALL downstream
steps to `ready` at job creation, so a job showed in every future stage
at once (e.g. WO-30061 across racking/receiving/plating/inspection) even
though no parts had advanced there.

Fix: a stage shows ONLY where parts physically are (qty_at_step > 0,
which includes the first-active seed) OR where a step is in_progress/
paused. A merely ready/pending future step with no parts no longer shows.
Strict sequential progress falls out for free — the qty_at_step seed sits
on the lowest-sequence non-terminal step and advances as each completes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:30:51 -04:00
gsinghpal
ca44461b6f feat(fusion_plating): partial order handling on the shop floor
Operators can now see and advance a job's parts across multiple stages
at once (e.g. 10 Masking / 20 Plating / 20 Baking on one 50-part job).
Tracking model C (fluid per-stage quantities + existing hold/scrap/
rework records for exceptions); board option 2 (a card per occupied
stage); wait-to-reconverge close. Additive only — no new model, no
migration, no change to the close/cert/ship lifecycle.

Board (fusion_plating_shopfloor/controllers/plant_kanban.py):
- One card PER (job, stage), composite key "{job_id}:{area}". Unsplit
  jobs render exactly as before. _job_presences/_render_presence;
  primary presence keeps full job card_state, secondary presences
  derive state from their focus step.

Card (plant_card.js/.xml/.scss):
- "20 of 50 here" badge; tap opens the workspace focused on that
  stage's step (focus_step_id, already accepted by the workspace).

Move + light-up (move_controller.py, fusion_plating_jobs/fp_job_step.py):
- Availability/pre-fill now from qty_at_step (step had no qty_done/
  qty_scrapped fields — the old read was always 0, dead path).
- Forward move auto-flips destination pending->ready (no auto-start;
  labour timer stays explicit) and auto-finishes a drained source
  (best-effort). Predecessor gate is qty-aware: a step with real
  arrived parts is startable regardless of upstream completion
  (_fp_has_real_incoming, single source of truth for can_start /
  blocker / button_start / move blockers).

Operator advance (job_workspace.js):
- "Send -> <next>" action on in_progress/paused steps opens the slimmed
  Move dialog (qty steppers, no keyboard; advanced fields collapsed).
  Was only wired into the deprecated shopfloor_tablet before.

Close (fp_job.py):
- button_mark_done counts move-based scrap (_fp_scrapped_via_moves) into
  qty_scrapped and derives qty_done = qty - scrapped (was blindly
  = job.qty, over-counting). Reconciliation gate unchanged.

Static-validated: pyflakes (py), lxml parse (xml), node --check (js).
Dynamic tests + browser check need an installed env (entech/trial) —
plating modules can't install on the local Community DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:30:51 -04:00
gsinghpal
249adf8145 docs(fusion_plating): shop-floor partial order handling design spec
Design for parts fanning across shop-floor stages (e.g. 10 at Masking,
20 at Plating, 20 at Baking on one 50-part job):

- Tracking model C — fluid per-stage quantities via existing qty_at_step;
  failed/held/rework subsets ride existing hold/scrap/rework records.
- Board Option 2 — a card per stage-presence (composite job:area keys);
  unsplit jobs render identically to today.
- Easy-advance operator flow — one "Send to next" action, steppers /
  rack-tap (no keyboard), intent-named Hold/Scrap/Rework buttons.
- Light-up plumbing — auto-ready on arrival, qty-aware predecessor gate,
  auto-finish source on drain; no auto-start (labour accuracy).
- Close — wait to reconverge; close/cert/ship/invoice lifecycle unchanged.

Additive only: no new core model, no data migration, no change to the
quantity model, OWL component tree, or close lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:30:51 -04:00
gsinghpal
cc568b0ec8 docs(fusion_maintenance): Plan 1 (Foundation) implementation plan + Plans 2-5 roadmap
TDD plan for the enrollment+pricing foundation: maintenance policy fields on the equipment category (+ product fee override), maintenance-contract extensions, fix+wire the dead _spawn_maintenance_contracts into the existing action_confirm (delivery-date anchor, two-regime serial dedup, fee snapshot), fee line in the reminder email, category UI, version 19.0.2.3.0. Grounded in real source. Plans 2-5 (booking on fusion_tasks, visit log + checklist, two-regime backfill, office crons) roadmapped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:27:38 -04:00
gsinghpal
17d21bffb5 docs(fusion_maintenance): correct backfill for lifts (no serials) after live sizing
Live sizing on Westin: stair lifts ~254 customers / porch-VPL ~30 / lift chairs ~41, but lift serial coverage ~0 (12/416 stairlift lines). The serial-as-unit-key approach (valid for ADP wheelchairs) fails for lifts. Backfill now splits into two regimes: serial dedup for wheelchairs; partner+base-product+sale-line dedup for lifts with accessory-line exclusion via the per-product maintainable flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:21:08 -04:00
gsinghpal
6c3830fd4c docs(fusion_maintenance): approved design spec — extend fusion_repairs (booking, backfill, flat-fee)
Build maintenance INTO fusion_repairs (engine ~90% already there): per-category policy (interval + flat fee, product override); fix the dead contract-spawn trigger for new sales + a one-time idempotent backfill of the existing install base (lifts + fusion_claims wheelchairs); technician-aware self-serve booking on fusion_tasks availability (NO Enterprise appointment) creating a technician task; structured maintenance visit log + inspection cert for lifts; office follow-up crons; cost shown to client. Out of v1: SMS, /my/equipment, route optimization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:14:49 -04:00
gsinghpal
12d383a8c2 docs(fusion_maintenance): add Westin install-base sizing to Step 0 results
Sized the real serial-tracked install base on sale.order.line: ~138 units / ~136 customers across all funders (walkers 68, wheelchairs 45, power bases 7, scooters 4, +14 with no ADP device_type). Serial# is captured ~only on equipment, so it doubles as a trackable-unit marker. ADP-only gating misses ~28 units (direct_private/adp_odsp/march_of_dimes) -> bridge should key on serial, funder-agnostic. Flags two data gaps (no-device_type units; non-ADP units lacking delivery_date) and reframes the MVP open question as volume (walkers/chairs) vs margin (powered units).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:33:12 -04:00
gsinghpal
139e917e09 docs(fusion_maintenance): record Step 0 live-grounding results from Westin prod
Ran Step 0 against Westin prod (westin-v19 on odoo-westin). Resolved the APP/DB placeholders (DO boxes dead; migrated on-prem to odoo-dev-app), added a dated STEP 0 RESULTS section, and corrected the open questions the live inspection disproved: no stair/porch lifts in Westin ADP data; Enterprise appointment already ships native token booking; fusion_repairs contract engine not deployed; device_type is the ADP billing-code catalog taxonomy, not the install base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:22:51 -04:00
Claude
de3e0df5fc docs(fusion_maintenance): brainstorm + handoff brief for connected-env session
Captures the maintenance-followup design exploration so it can resume from a
Tailscale-connected environment with access to Westin production:
- fusion_repairs already has a maintenance contract/reminder/booking engine to reuse
- fusion_claims (sale.order.line + adp.device.code.device_type) is the trigger source
- locked decisions: same DB, Enterprise appointment, public self-serve token booking
- Step 0 live-inspection command pack to run on Westin prod before any code
- open questions (MVP cut, revenue mechanic, tech assignment, booking route)

https://claude.ai/code/session_011wfSKQfSWhKZcm1yzSGznW
2026-06-02 04:01:54 +00:00
32 changed files with 1892 additions and 148 deletions

View File

@@ -0,0 +1,194 @@
# fusion_maintenance — Brainstorm & Handoff Brief
> Status: **research/brainstorm only — no code, no final decisions.** Written from a
> Claude Code *web* session that could **not** reach the private network (no Tailscale,
> no docker daemon, Supabase KB unreachable). Resume from a **Tailscale-connected env**
> (dev box or a host that can reach Westin production) and do the live inspection in
> Step 0 **before** committing to the design.
## Goal (user's words, paraphrased)
Automated maintenance follow-ups for mobility/accessibility equipment we've sold, to turn
service into **recurring revenue**. Reminder emails → client books maintenance → booking
happens in **real time** and **lands in our calendar**. Leverage Odoo Enterprise's
appointment system. Decide whether this lives in `fusion_repairs` or a new module — the
result must be **seamless and production-ready**.
## Decisions locked with the user (this session)
- **Same DB**: `fusion_claims` + `fusion_repairs` run on one database → new module may depend on both.
- **Enterprise `appointment` is available** → build real-time booking ON it (`appointment.type` /
`appointment.slot` / `calendar.event`), do **not** hand-roll a calendar.
- **Public self-serve booking** → reminder email carries a token link to a no-login slot picker
(extend the existing `/repairs/maintenance/book/<token>` pattern). Elderly clients shouldn't log in.
- **Target box for grounding = Westin production** (where `fusion_claims` runs day-to-day).
## Key findings from repo exploration
### `fusion_repairs` (v19.0.2.2.6) ALREADY has a maintenance engine — reuse it, don't fork
- `fusion.repair.maintenance.contract`: interval, due/last-service dates, state machine.
Auto-spawned on SO confirm when `product.template.x_fc_maintenance_interval_months > 0`.
- Daily reminder cron `cron_maintenance_due_reminders` → 30/7/1-day bands → branded email
`email_template_maintenance_due_reminder` with tokenized link `/repairs/maintenance/book/<token>`.
- Booking controller: `controllers/portal_maintenance_booking.py` — **single date-confirm form,
NO slot availability, NO conflict check, NO calendar event.** ← this is the real gap.
- Contract **roll-forward** on technician-task completion (`next_due_date += interval`).
- `fusion.repair.service.plan.subscription`: pre-paid visit plans (recurring-revenue primitive).
- Deps: `repair, maintenance, sale_management, stock, purchase, website, portal, fusion_tasks,
fusion_poynt, fusion_authorizer_portal`. ~8.3k LOC, 25+ models.
### `fusion_claims` (v19.0.9.2.0) is the ideal trigger source
- Claim container = `sale.order` (`x_fc_sale_type`: adp, odsp, wsib, insurance, march_of_dimes, …).
- **Equipment unit** = `sale.order.line.x_fc_serial_number` + `product_id`.
- **Equipment category** = `fusion.adp.device.code.device_type` (wheelchair, walker, hospital bed,
stair lift, porch lift, custom ramp, …) — matches the user's "sale groups".
- **Schedule anchors**: `x_fc_adp_delivery_date`, `x_fc_service_start_date`; gate on `x_fc_adp_approved`.
- Customer = `sale.order.partner_id`; prescriber = `x_fc_authorizer_id`.
- Already depends on `calendar, fusion_tasks, ai, fusion_ringcentral`.
## Proposed architecture (PENDING live verification)
**New module `fusion_maintenance`** depending on `fusion_repairs`, `fusion_claims`, `appointment`.
Reuses the existing contract/reminder/roll-forward engine; adds the 3 genuinely-missing pieces:
1. **`fusion.maintenance.policy`** (ops-configurable, no code per category):
`device_type` → `interval_months`, reminder bands, `service_product_id` (priced visit),
`appointment_type_id`, required technician skill. Turns "stair lift = 6 mo, $X" into data.
2. **Claims bridge** (daily cron): scan `fusion_claims` `sale.order.line` for delivered+approved
devices whose `device_type` matches an active policy → ensure a maintenance contract exists,
anchored at `delivery_date + interval`. Idempotent (key on serial / sale-line). Extend the
reused contract with `x_fc_source_claim_line_id`, `x_fc_device_type`, `x_fc_policy_id` so the
repairs path and claims path both feed **one** contract model.
3. **Real-time booking on `appointment`**: token link → slot picker backed by `appointment.type`
(partner pre-resolved from token, no login). Slot pick → real `calendar.event` → hook spawns
`repair.order` + technician task, assigns by skill/zone, advances reminder band, rolls contract
forward.
**Recurring revenue**: each policy carries `service_product_id` → booked visit drafts a priced
SO/invoice; optional pre-paid annual plan via existing `service.plan.subscription`; optional
door payment via existing `fusion_poynt`.
## STEP 0 — run on Westin production FIRST (grounding before any decision)
> Replace `APP`/`DB` with the real Westin container + database. CLAUDE.md rule #1: never code
> from memory — read the real Enterprise `appointment` source before building the booking layer.
```bash
# RESOLVED 2026-06-02 — Westin Odoo prod migrated OFF Digital Ocean onto the on-prem Proxmox
# cluster. Old DO IPs (152.42.146.204 / 178.128.229.92) are DEAD (:22 timeout). Live box:
# host `odoo-westin` = 192.168.1.40 via the `supabase-prod` Tailscale jump (Windows OpenSSH
# ProxyCommand → run `ssh odoo-westin ...` from PowerShell). App container `odoo-dev-app`
# (odoo:19, Enterprise); DB container `odoo-dev-db`; DB `westin-v19`; user `odoo` (local-socket
# trust inside odoo-dev-db). Enterprise addons → /mnt/enterprise-addons, custom → /mnt/extra-addons.
# SQL: ssh odoo-westin 'docker exec odoo-dev-db psql -U odoo -d westin-v19 -c "..."'
# FS read: ssh odoo-westin 'docker exec odoo-dev-app sed -n 1,160p /mnt/enterprise-addons/...'
APP=odoo-dev-app ; DB=westin-v19 ; DBC=odoo-dev-db
# 1) Install matrix — confirm same-DB + Enterprise appointment present + versions
docker exec "$APP" psql -U odoo -d "$DB" -c \
"SELECT name,state,latest_version FROM ir_module_module \
WHERE name IN ('fusion_claims','fusion_repairs','fusion_maintenance','calendar','maintenance','repair') \
OR name LIKE 'appointment%' ORDER BY name;"
# 2) Real device_type distribution (drives per-category policies)
docker exec "$APP" psql -U odoo -d "$DB" -c \
"SELECT device_type, count(*) FROM fusion_adp_device_code GROUP BY device_type ORDER BY 2 DESC;"
# 3) Locate the Enterprise appointment source (read, don't guess the API)
docker exec "$APP" bash -lc 'ls -d /mnt/enterprise-addons/appointment 2>/dev/null || \
find / -maxdepth 6 -type d -name appointment 2>/dev/null | grep -i addons | head'
# 4) Appointment model surface to build booking on (adjust path from #3)
docker exec "$APP" cat <appointment_path>/models/appointment_type.py | head -160
docker exec "$APP" ls <appointment_path>/controllers/ # find the public booking controller
# 5) How fusion_repairs maintenance contracts already look in live data
docker exec "$APP" psql -U odoo -d "$DB" -c \
"SELECT state, count(*) FROM fusion_repair_maintenance_contract GROUP BY state;"
```
## STEP 0 — RESULTS (ran 2026-06-02 against Westin prod `westin-v19`)
> Grounding facts only — **no design decisions made**. These correct several assumptions above.
**Connection (resolved):** host `odoo-westin` (192.168.1.40) via the `supabase-prod` Tailscale jump.
App container `odoo-dev-app` (odoo:19, Enterprise), DB container `odoo-dev-db`, DB `westin-v19`,
user `odoo`. Old Digital Ocean boxes are DEAD — Westin migrated on-prem.
**1) Install matrix** — `appointment` **19.0.1.3 installed** (+ `appointment_account_payment`,
`_crm`, `_hr`, `_microsoft_calendar`, `_sms`). All deps present: `calendar`, `maintenance`, `repair`,
`sale_management`, `portal`, `website`, `resource`, `phone_validation`, `web_gantt`. `fusion_claims`
**19.0.9.2.0 installed**. `fusion_repairs` and `fusion_maintenance` are **absent entirely** (no
records). → a module depending on `appointment` installs cleanly; "reuse the fusion_repairs engine"
means *deploy fusion_repairs to Westin first* (heavy) **or** own a lean contract model here. Note
Odoo's native `maintenance` (CMMS) is installed — an under-considered third reuse option.
**2) device_type** — 119 distinct values, but `fusion.adp.device.code` is the ADP billing-code
**CATALOG** (`_order='device_type, device_code'`), so counts are catalog codes per type, **NOT units
installed**. Top entries are seating COMPONENTS (Seat Cushion 564, Back Support 375, Headrest 193).
The maintainable **equipment classes** ≈ wheelchairs (manual + power tilt), power bases, power
scooters, wheeled walkers / walking frames, paediatric standing frames, specialty strollers (~6-8
clean categories). → `device_type` can't be a 1:1 policy key (119 values, mostly parts); needs a
grouping/whitelist. **Real install base sized on `sale.order.line`** (`x_fc_adp_device_type` [stored compute from
product's `x_fc_adp_device_code_id.device_type`], `x_fc_serial_number`, `x_fc_adp_approved`; delivery
dates `x_fc_adp_delivery_date` / `x_fc_service_start_date`) — **see the Install-base sizing block below.**
**3) + 4) Enterprise appointment source** — `/mnt/enterprise-addons/appointment`. The no-login token
slot-picker is **mostly NATIVE — don't hand-roll it**: public booking (`auth="public"`), invite
tokens (`appointment.invite`, `/appointment/<id>?…invite_token`), live availability
(`/appointment/<id>/update_available_slots`, jsonrpc/public), slot submit → real `calendar.event`
(`/appointment/<id>/submit`), auto/manual staff+resource assignment, capacity, booked/cancelled mail
templates. Model `appointment.type`; controller `controllers/appointment.py`. → the module mainly
needs to: seed an `appointment.type` per category, drop a partner-bound invite link into the reminder
email, and hook `calendar.event` create → spawn the service task + advance the contract.
`appointment_account_payment` is installed → native pay-to-book is on the table for the revenue mechanic.
**5) Maintenance-contract state** — `relation "fusion_repair_maintenance_contract" does not exist`
→ confirms the fusion_repairs maintenance engine is **not** on Westin.
**Headline correction:** Westin's ADP data has **zero** stair lifts / porch lifts / ramps / hospital
beds — those belong to the fusion_repairs / EN-Tech (mobility) domain. Westin's recurring-revenue
play is **wheelchairs / power bases / scooters / walkers / seating**. Open questions updated below.
**Install-base sizing (ran 2026-06-02 — the REAL units, complementing #2's catalog counts).** Big tell:
serial numbers are captured **~only on actual equipment** (every part/option/mod device_type shows 0
serials), so `x_fc_serial_number` is already a de-facto "trackable unit" marker — convenient, because the
bridge's idempotency key is the serial.
- **Addressable base ≈ 138 serial-tracked units across ~136 customers** (all funders). By equipment
family (serial-tracked / of which delivered): **Walkers & walking frames 68 (55)**, **Wheelchairs 45
(40)**, **Power bases 7 (6)**, **Scooters 4 (3)**, plus **14 units with no ADP device_type** (likely
private-pay) and 1 misc.
- **Funder split** (serial-tracked): adp 109, direct_private 13, adp_odsp 10, march_of_dimes 7;
wsib / insurance / standalone-odsp / rental / regular = **0 serials**. → an ADP-only gate
(`x_fc_adp_approved`) captures ~110 and **misses ~28** real units. The bridge should likely key on
**serial (funder-agnostic)**, not approval.
- **Two data gaps the design must absorb:** (a) the 14 serial units with no ADP device_type can't be
classified by a device_type→policy map → need a product-level or manual category override; (b) non-ADP
units have no `x_fc_adp_delivery_date` → the contract anchor (`delivery_date + interval`) needs a
fallback (invoice/order date).
- Deliveries span **2022-10 → 2026-05** (active program) — history to anchor intervals + a live pipeline.
- Top serial-tracked device_types: Adult Wheeled Walker Type 3 (47), Adult Manual Dynamic Tilt Type 5
Wheelchair (23), Adult Lightweight Performance Type 3 (11), Adult Lightweight Standard Type 1 (10),
Adult Wheeled Walker Type 2 (9), Adult Power Base Type 3 (5), Power Scooter (3). (1 line ≈ 1 unit;
equipment device_types are 1 base line each.)
## Open questions to resolve with the user (in the connected session)
- **MVP cut**: which categories first? Sizing surfaces a real tension: **by volume** it's walkers (68) +
wheelchairs (45) ≈ 82% of the base, but rollators/walkers are mechanically low-service; **by
service-revenue-per-unit** the targets are the powered units (power bases 7 + scooters 4 + power
wheelchairs) — high maintenance value but only ~1115 units today. Volume vs. margin — or phase it
(powered units first to prove the booking loop, then walkers/manual chairs for reach)?
- **Revenue mechanic**: auto-draft a priced SO/invoice per booking, vs. pre-paid annual plan, vs.
pay-at-door via Poynt — which is the default?
- **Technician assignment**: auto-assign by skill+zone at booking time, or leave dispatch manual
(fusion_tasks) and only reserve the calendar slot?
- **Booking-portal strategy**: Step 0 shows Enterprise `appointment` already ships public,
token-based real-time booking (`appointment.invite` + `/appointment/<id>/...`, `auth="public"`).
Ride on that (generate an invite per reminder, partner pre-bound, no login) vs. a custom
`/maintenance/book/<token>` route? (The `/repairs/...` route is moot — fusion_repairs isn't on Westin.)
## Applicable CLAUDE.md rules (don't relearn the hard way)
- Rule #1: read reference files from the running instance before coding (esp. the appointment source).
- Odoo 19: `res.users.group_ids` (not `groups_id`); `ir.cron` has no `numbercall`; declarative
`models.Constraint`/`models.Index`; HTTP routes `type="jsonrpc"`; OWL uses standalone `rpc()`.
- No `sale.subscription` model exists — a subscription is a `sale.order` with `is_subscription=True`.
- New fields use `x_fc_` prefix; Canadian English; `$` Monetary + `currency_id`.
- Route attachment opens through `fusion_pdf_preview` (`att.action_fusion_preview(...)`).
- Tests need `--http-port=0 --gevent-port=0`. Westin prod is Enterprise; local dev is Community
(so the appointment-dependent module can't be installed/tested on `odoo-modsdev-app`).

View File

@@ -0,0 +1,43 @@
# Accessibility Funding-Source Selector — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline) — this is a 3-file change. Steps use `- [ ]` checkboxes.
**Goal:** Let the rep mark an accessibility assessment's funding source (Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) on the web form, so the generated sale order routes to the correct funding pipeline instead of always defaulting to private pay.
**Architecture:** The model (`fusion.accessibility.assessment.x_fc_funding_source`) and the SO routing (`_create_draft_sale_order``sale_type_map``x_fc_sale_type`) already exist (the "2026-04 portal audit fix"). The only gaps: (1) the form has no funding field, (2) the save controller never reads `funding_source` from the POST, (3) `hardship` is missing from the selectable funding sources. The submit JS already serialises every named form field via `FormData`, so no JS change is needed.
**Tech Stack:** Odoo 19, QWeb portal template, JSON-RPC controller. Module `fusion_portal` (worktree `K:\Github\Odoo-Modules-wt-portal`, branch `feat/assessment-visit`).
**Verification constraint:** `fusion_portal` depends on Enterprise `knowledge`, so it can NOT be installed on the local Community Docker. Syntax-check with host Python; functional verification is on westin (or a clone): pick "March of Dimes" on a form → the draft SO gets `x_fc_sale_type='march_of_dimes'` and lands in the MOD pipeline.
---
### Task 1: Add Hardship to the funding source + route it
**Files:** Modify `fusion_portal/models/accessibility_assessment.py` (selection ~:71-87, `sale_type_map` ~:771-779)
- [ ] **Step 1:** Add `('hardship', 'Hardship Funding')` to the `x_fc_funding_source` selection list (after `'wsib'`).
- [ ] **Step 2:** Add `'hardship': 'hardship',` to `sale_type_map` in `_create_draft_sale_order` (the target `x_fc_sale_type='hardship'` already exists in `fusion_claims` `sale_order.py:332`).
- [ ] **Step 3:** `python -m py_compile fusion_portal/models/accessibility_assessment.py` → no error.
- [ ] **Step 4:** Commit.
### Task 2: Add the funding select to the shared client-info form
**Files:** Modify `fusion_portal/views/portal_accessibility_templates.xml` (`accessibility_client_info_section`, ~:366-375)
- [ ] **Step 1:** Add a new row with a `<select name="funding_source">` (options mirror the model selection; `direct_private` pre-selected so existing private behaviour is unchanged) right after the phone/email row, before the card closes.
- [ ] **Step 2:** Validate XML well-formedness (`[xml]` parse).
- [ ] **Step 3:** Commit.
### Task 3: Capture funding_source in the save controller
**Files:** Modify `fusion_portal/controllers/portal_main.py` (`accessibility_assessment_save` vals, ~:2498-2511)
- [ ] **Step 1:** Add `'x_fc_funding_source': post.get('funding_source') or 'direct_private',` to the `vals` dict.
- [ ] **Step 2:** `python -m pyflakes fusion_portal/controllers/portal_main.py` → no new undefined-name errors.
- [ ] **Step 3:** Commit.
### Task 4: Verify + ship
- [ ] **Step 1:** Grep confirms `funding_source` flows form → controller → `x_fc_funding_source``sale_type_map`.
- [ ] **Step 2:** Deploy to westin (backup → scp the 3 files → `-u fusion_portal` → cache-bust → restart) and confirm: open `/my/accessibility/stairlift/straight`, pick "March of Dimes", complete → the new SO shows `x_fc_sale_type = march_of_dimes` and appears in the MOD pipeline.

View File

@@ -0,0 +1,506 @@
# fusion_maintenance Foundation — Implementation Plan (Plan 1 of 5)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Confirming a sale of a maintainable product auto-creates a *priced* maintenance contract, and the due-reminder email shows the maintenance cost.
**Architecture:** Extend `fusion_repairs`. A maintenance **policy** (enabled / interval / flat fee) lives on `fusion.repair.product.category`, with a per-product fee/interval override on `product.template`. We fix the dead `_spawn_maintenance_contracts()` (anchor on delivery date, capture serial + fee + provenance, dedup) and call it from the **existing** `action_confirm()` override. The branded reminder email gains a fee line.
**Tech Stack:** Odoo 19 **Community**, Python, `TransactionCase`. Local dev: `docker odoo-modsdev-app`, DB `fusion-dev`.
**Spec:** [`2026-06-02-fusion-maintenance-design.md`](../specs/2026-06-02-fusion-maintenance-design.md). This is **Plan 1 of 5**; see the Roadmap at the bottom for Plans 25 (booking, visit log, backfill, office crons) — each is written when reached because it needs its own live-source reads (spec §15).
**Conventions (from CLAUDE.md):** new fields `x_fc_` prefix; Canadian English; Monetary = `$` + `currency_id`; declarative `models.Constraint` / `models.Index` (no `_sql_constraints`); `message_post` HTML wrapped in `Markup()`; `res.users` group field is `group_ids`.
**Run tests:**
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs \
-u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
**Grounding (verified source, 2026-06-02):**
- [`maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py) — contract model (fields end at `company_id`, line 81; `_booking_token_unique` constraint line 83); dead `_spawn_maintenance_contracts()` (line 198, anchors on `today`, dedups by partner/product/SO, no fee/serial/source).
- [`repair_product_category.py`](../../../fusion_repairs/models/repair_product_category.py) — category model; `safety_critical`, `equipment_class`; `_code_unique` constraint line 56.
- [`product_template.py`](../../../fusion_repairs/models/product_template.py) — `x_fc_repair_category_id` (line 11), `x_fc_maintenance_interval_months` (line 23, default 0).
- [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py) — **existing** `action_confirm()` override (line 229) ending `return res` (line 250); wire the maintenance spawn here.
---
## File Structure
- **Modify** `fusion_repairs/models/repair_product_category.py` — add maintenance-policy fields + `currency_id`.
- **Modify** `fusion_repairs/models/product_template.py` — add `x_fc_maintenance_fee` override.
- **Modify** `fusion_repairs/models/maintenance_contract.py` — add contract fields + indexes; add `_fc_maintenance_anchor_date`; rewrite `_spawn_maintenance_contracts`.
- **Modify** `fusion_repairs/models/repair_service_plan.py` — call `self._spawn_maintenance_contracts()` inside `action_confirm`.
- **Modify** `fusion_repairs/data/mail_template_data.xml` — add a fee row to the reminder template.
- **Modify** `fusion_repairs/views/repair_product_category_views.xml` — expose the policy fields.
- **Create** `fusion_repairs/tests/__init__.py`, `fusion_repairs/tests/test_maintenance_foundation.py`.
- **Modify** `fusion_repairs/__manifest__.py` — bump `version` to `19.0.2.3.0`.
> **Scope note:** the technician-skill field (`x_fc_maintenance_skill_id`) is deferred to **Plan 2 (booking)** because skill matching is a booking concern and the exact skills representation is an open item (spec §15). Plan 1 is enrollment + pricing only.
---
## Task 1: Maintenance policy fields on the equipment category
**Files:**
- Modify: `fusion_repairs/models/repair_product_category.py` (insert after `intake_template_id`, before `_code_unique` at line 56)
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
- [ ] **Step 1: Create the tests package + write the failing test**
Create `fusion_repairs/tests/__init__.py`:
```python
from . import test_maintenance_foundation
```
Create `fusion_repairs/tests/test_maintenance_foundation.py`:
```python
# -*- 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',
'equipment_class': 'lift_elevating', 'safety_critical': True,
'x_fc_maintenance_enabled': True,
'x_fc_maintenance_interval_months': 6,
'x_fc_maintenance_fee': 149.0,
})
def test_category_policy_fields_exist(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)
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
```
Expected: FAIL — `Invalid field 'x_fc_maintenance_enabled' on model 'fusion.repair.product.category'`.
- [ ] **Step 3: Add the policy fields**
In `repair_product_category.py`, insert before the `_code_unique = models.Constraint(...)` line:
```python
# ── 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.',
)
```
- [ ] **Step 4: Run the test to verify it passes**
Run the same command as Step 2. Expected: `test_category_policy_fields_exist` PASS.
- [ ] **Step 5: Commit**
```bash
git add fusion_repairs/models/repair_product_category.py fusion_repairs/tests/
git commit -m "feat(fusion_repairs): maintenance policy fields on equipment category"
```
---
## Task 2: Per-product fee override
**Files:**
- Modify: `fusion_repairs/models/product_template.py` (after `x_fc_maintenance_interval_months`, line 28)
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
- [ ] **Step 1: Write the failing test** (append to the test class)
```python
def test_product_fee_override_field_exists(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)
```
- [ ] **Step 2: Run to verify it fails**
Run the test command. Expected: FAIL — `Invalid field 'x_fc_maintenance_fee' on model 'product.template'`.
- [ ] **Step 3: Add the field**
In `product_template.py`, after the `x_fc_maintenance_interval_months` field (line 28):
```python
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.',
)
```
(`product.template` already provides `currency_id`.)
- [ ] **Step 4: Run to verify it passes**`test_product_fee_override_field_exists` PASS.
- [ ] **Step 5: Commit**
```bash
git add fusion_repairs/models/product_template.py fusion_repairs/tests/test_maintenance_foundation.py
git commit -m "feat(fusion_repairs): per-product maintenance fee override"
```
---
## Task 3: Contract model extensions (fee, source, serial, policy)
**Files:**
- Modify: `fusion_repairs/models/maintenance_contract.py` (add fields after `company_id`, line 81; add indexes near `_booking_token_unique`, line 83)
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
- [ ] **Step 1: Write the failing test**
```python
def test_contract_extension_fields_exist(self):
c = self.env['fusion.repair.maintenance.contract'].create({
'partner_id': self.partner.id,
'product_id': self.env['product.product'].create({'name': 'Unit'}).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)
```
- [ ] **Step 2: Run to verify it fails**`Invalid field 'x_fc_source' ...`.
- [ ] **Step 3: Add the fields + indexes**
In `maintenance_contract.py`, after the `company_id` field (line 81), before `_booking_token_unique`:
```python
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',
)
```
(Idempotency is enforced in Python — Task 4 — to support the two-regime dedup in spec §6.2; the `index=True` above covers lookups.)
- [ ] **Step 4: Run to verify it passes**`test_contract_extension_fields_exist` PASS.
- [ ] **Step 5: Commit**
```bash
git add fusion_repairs/models/maintenance_contract.py fusion_repairs/tests/test_maintenance_foundation.py
git commit -m "feat(fusion_repairs): maintenance contract fee/source/serial/policy fields"
```
---
## Task 4: Spawn priced contracts on sale confirm (fix the dead trigger + wire it)
**Files:**
- Modify: `fusion_repairs/models/maintenance_contract.py` (rewrite `_spawn_maintenance_contracts`, lines 198-227; add `_fc_maintenance_anchor_date` helper)
- Modify: `fusion_repairs/models/repair_service_plan.py` (call it in `action_confirm`, before `return res` at line 250)
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
- [ ] **Step 1: Write the failing tests**
```python
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', '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 + 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
self.assertEqual(len(self._contracts_for(so)), 1)
```
- [ ] **Step 2: Run to verify they fail** — contracts not created (trigger not wired) → assertions fail.
- [ ] **Step 3: Rewrite `_spawn_maintenance_contracts` + add the anchor helper**
Replace the body of `_spawn_maintenance_contracts` (lines 198-227) and add the helper, in the `SaleOrder` class of `maintenance_contract.py`:
```python
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):
"""Create a priced maintenance contract per maintainable unit on a confirmed SO.
Policy = product interval override, else the product's category policy.
Idempotent: by serial when captured, else by source sale line."""
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
for so in self:
if so.state not in ('sale', 'done'):
continue
for line in so.order_line:
product = line.product_id
if not product:
continue
tmpl = product.product_tmpl_id
category = tmpl.x_fc_repair_category_id
product_interval = tmpl.x_fc_maintenance_interval_months or 0
cat_enabled = bool(category) and category.x_fc_maintenance_enabled
interval = product_interval or (
category.x_fc_maintenance_interval_months if cat_enabled else 0)
if interval <= 0 or not (product_interval > 0 or cat_enabled):
continue
fee = tmpl.x_fc_maintenance_fee or (
category.x_fc_maintenance_fee if category else 0.0)
# Capture serial only if fusion_claims' line field is present.
serial = ''
if 'x_fc_serial_number' in line._fields:
serial = (line.x_fc_serial_number or '').strip()
# Idempotency: serial regime vs source-line regime (spec §6.2).
if serial:
dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)]
else:
dedup = [('state', '=', 'active'),
('x_fc_source_sale_line_id', '=', line.id)]
if Contract.search_count(dedup):
continue
anchor = so._fc_maintenance_anchor_date(line)
# One contract per serialized unit; without a serial, per quantity.
count = 1 if serial else max(int(line.product_uom_qty or 1), 1)
for _i in range(count):
Contract.create({
'partner_id': so.partner_id.id,
'product_id': product.id,
'original_sale_order_id': so.id,
'x_fc_source_sale_line_id': line.id,
'x_fc_source': 'sale',
'x_fc_device_serial': serial,
'x_fc_policy_category_id': category.id if category else False,
'interval_months': interval,
'x_fc_maintenance_fee': fee,
'next_due_date': anchor + relativedelta(months=interval),
'state': 'active',
})
```
- [ ] **Step 4: Wire it into the existing `action_confirm`**
In `repair_service_plan.py`, in `action_confirm`, change line 249-250 from:
```python
self._fc_spawn_labor_warranties()
return res
```
to:
```python
self._fc_spawn_labor_warranties()
self._spawn_maintenance_contracts()
return res
```
- [ ] **Step 5: Run to verify the Task-4 tests pass** — all four PASS.
- [ ] **Step 6: Commit**
```bash
git add fusion_repairs/models/maintenance_contract.py fusion_repairs/models/repair_service_plan.py fusion_repairs/tests/test_maintenance_foundation.py
git commit -m "feat(fusion_repairs): spawn priced maintenance contracts on sale confirm"
```
---
## Task 5: Show the fee in the reminder email
**Files:**
- Modify: `fusion_repairs/data/mail_template_data.xml` (the `email_template_maintenance_due_reminder` record)
- [ ] **Step 1: Read the current template**
Run:
```bash
docker exec odoo-modsdev-app sh -c "grep -n 'email_template_maintenance_due_reminder' /mnt/odoo-modules/fusion_repairs/data/mail_template_data.xml"
```
Then open that record's `<field name="body_html">` and find the equipment-name / due-date details table (the green-accent reminder).
- [ ] **Step 2: Add a fee row to the details table**
Inside the details table of the reminder body, after the "Next due" row, add (Canadian English, `$` + currency):
```xml
<tr t-if="object.x_fc_maintenance_fee">
<td style="opacity:0.6;width:35%;">Maintenance fee</td>
<td><span t-field="object.x_fc_maintenance_fee"
t-options='{"widget": "monetary", "display_currency": object.currency_id}'/>
<span style="opacity:0.6;"> + applicable tax</span></td>
</tr>
```
- [ ] **Step 3: Upgrade + manually verify the rendered email**
Run:
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init
```
Then in odoo-shell render the template for a contract with a fee and confirm the fee line appears:
```bash
docker exec odoo-modsdev-app odoo shell -d fusion-dev --no-http <<'PY'
c = env['fusion.repair.maintenance.contract'].search([('x_fc_maintenance_fee','>',0)], limit=1)
tpl = env.ref('fusion_repairs.email_template_maintenance_due_reminder')
print('FEE' if 'applicable tax' in tpl._render_field('body_html', c.ids)[c.id] else 'MISSING')
PY
```
Expected: `FEE`.
- [ ] **Step 4: Commit**
```bash
git add fusion_repairs/data/mail_template_data.xml
git commit -m "feat(fusion_repairs): show maintenance fee in due-reminder email"
```
---
## Task 6: Expose policy fields in the category form + bump version
**Files:**
- Modify: `fusion_repairs/views/repair_product_category_views.xml`
- Modify: `fusion_repairs/__manifest__.py`
- [ ] **Step 1: Read the category form view**
Run:
```bash
docker exec odoo-modsdev-app sh -c "grep -n 'fusion.repair.product.category' /mnt/odoo-modules/fusion_repairs/views/repair_product_category_views.xml | head"
```
Locate the `<form>` for the category.
- [ ] **Step 2: Add a Maintenance group to the form**
Inside the category form sheet, add:
```xml
<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>
```
- [ ] **Step 3: Bump the version**
In `fusion_repairs/__manifest__.py`, change `'version': '19.0.2.2.6',` to `'version': '19.0.2.3.0',`.
- [ ] **Step 4: Upgrade + run the full test module green**
Run:
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
```
Expected: all `TestMaintenanceFoundation` tests PASS, 0 failures, module loads.
- [ ] **Step 5: Commit**
```bash
git add fusion_repairs/views/repair_product_category_views.xml fusion_repairs/__manifest__.py
git commit -m "feat(fusion_repairs): category maintenance-policy UI + version 19.0.2.3.0"
```
---
## Self-Review (against the spec)
- **Spec §2 D2 (flat fee per type):** Tasks 1-2 (policy on category + product override), Task 4 (fee snapshot on contract), Task 5 (fee in email). ✓
- **Spec §3.2 gap #1 (dead trigger):** Task 4 fixes + wires `_spawn_maintenance_contracts`. ✓
- **Spec §3.2 gap #3 (no cost shown):** Task 5. ✓
- **Spec §5.1 / §5.2 (policy + contract fields):** Tasks 1-3. ✓
- **Spec §6.1 (new-sale path, delivery anchor, idempotent, serial when present):** Task 4 (`_fc_maintenance_anchor_date`, two-regime dedup, guarded serial capture). ✓
- **Deferred to Plan 2:** `x_fc_maintenance_skill_id` (skills representation is §15 open item) — noted in File Structure.
- **No placeholders:** every code step shows complete code; the two "read first" steps (Tasks 5-6) target XML whose exact surrounding markup must be read live before editing, and give the exact snippet to insert.
- **Type consistency:** `x_fc_maintenance_fee` Monetary + `currency_id` used identically on category, product, contract; `_spawn_maintenance_contracts` / `_fc_maintenance_anchor_date` names consistent between maintenance_contract.py and the call site in repair_service_plan.py.
---
## Roadmap — Plans 25 (write each when reached; each needs its own live-source reads per spec §15)
- **Plan 2 — Technician-aware booking** (the largest build): read `fusion_tasks/models/technician_task.py` `_find_next_available_slot` (line 544) / `_get_available_gaps` (line 664) signatures + working-hours source; add `x_fc_maintenance_skill_id` to the category and confirm the `res.users.x_fc_repair_skills` representation; replace the `<input type="date">` booking page with a real slot-picker controller; on confirm create a `fusion.technician.task` (`task_type='maintenance'`) + the maintenance `repair.order`; double-book guard; office "Book maintenance" action; per-cycle `booking_token` regen in `roll_next_due_date`. Delivers: real self-serve booking.
- **Plan 3 — Maintenance visit log + checklist**: read the visit-report wizard + the inspection-certificate (M1) API; add `fusion.repair.maintenance.visit` + `fusion.repair.maintenance.checklist.line`; seed checklists per category; issue an inspection certificate for `safety_critical` categories. Delivers: queryable per-unit history + compliance proof.
- **Plan 4 — Backfill wizard** (two-regime, spec §6.2): `fusion.repair.maintenance.backfill.wizard`; serial dedup for ADP wheelchairs (guarded `fusion_claims` read), partner+base-product+sale-line dedup for lifts with accessory-line exclusion; stagger; dry-run report → execute. Delivers: the existing install base enrolled.
- **Plan 5 — Office follow-up crons**: `unbooked` + `overdue` crons gated on the existing `ir.config_parameter` toggles; per-row savepoint isolation. Delivers: staff nudges when clients don't self-serve.

View File

@@ -0,0 +1,298 @@
# fusion_maintenance — Design Spec
> Automated preventivemaintenance followups + selfserve realtime booking for Westin
> medical mobility equipment (stair lifts, porch lifts, lift chairs, wheelchairs, power
> wheelchairs/scooters), to keep clients on schedule and turn service into recurring revenue.
| | |
|---|---|
| **Status** | Design **approved** (brainstorm dialogue 20260602). Ready for implementation plan. |
| **Implemented by** | **Extending `fusion_repairs`** (no new module). Version bump. |
| **Target instance** | Westin production — host `odoo-westin` (192.168.1.40), container `odoo-dev-app`, DB `westin-v19`. One company / one DB running `fusion_claims` (live) + `fusion_repairs` (to be deployed). |
| **Relates to** | [`docs/plans/fusion_maintenance_brainstorm.md`](../../plans/fusion_maintenance_brainstorm.md) (brief + Step 0 + sizing), [`2026-05-20-fusion-repairs-design.md`](2026-05-20-fusion-repairs-design.md) (base module). |
| **Next step** | `writing-plans` → implementation plan. **No code until the plan is written and this spec is reviewed.** |
---
## 1. Goal
Westin sells/services mobility equipment that needs preventive maintenance every **16 months
depending on the product**. Today there is no system keeping clients on schedule. We want:
1. The system **automatically emails the client** when a unit is due for maintenance.
2. The client can **book the visit themselves** (realtime, selfserve, no login) **or** call the
office and staff book it for them.
3. The booking **lands in our scheduling/calendar** as a real technician job.
4. The **technician accesses and updates the maintenance log** on the visit; the system keeps the
full history per unit.
5. The **next maintenance is autorescheduled** → recurring loop.
6. The client is **told the cost** up front.
7. Outcome: clients stay on track **and** Westin gains **recurring revenue**.
8. Design/UX stays **consistent with `fusion_claims`** (branded emails, `x_fc_` naming, Canadian
English, `$`+`currency_id`).
## 2. Locked decisions (from the brainstorm)
| # | Decision | Choice | Why |
|---|----------|--------|-----|
| D1 | Separate module vs. part of `fusion_repairs` | **Build into `fusion_repairs`** | The maintenance engine already lives there (~90% built); a separate module would duplicate it. fusion_repairs already owns the equipment categories, `repair.order`, technician tasks, service plans, and the Westin rate card. |
| D2 | Pricing / revenue model | **Flat fee per equipment type** | Transparent cost to show the client; recurring pervisit revenue. Configured per equipment **category** with perproduct override. |
| D3 | Enrollment scope | **New sales + backfill existing install base** | The recurring revenue and "keep clients on track" value is in the *existing* base, not just future sales. |
| D4 | Booking engine | **Technicianaware picker on `fusion_tasks`** (NOT Enterprise `appointment`) | Clients see only slots a qualified tech is genuinely free for (route/skillaware); booking creates the technician task directly — one scheduling world, no appointment↔task bridge. Bonus: **no Enterprise dependency → Communitytestable locally.** |
## 3. Grounding (verified, not assumed)
### 3.1 What `fusion_repairs` ALREADY has (reuse — do not rebuild)
Source: [`fusion_repairs/models/maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py), [`technician_task.py`](../../../fusion_repairs/models/technician_task.py), [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py), `cloud.md`.
- `fusion.repair.maintenance.contract` — partner/product/lot/original_SO, `interval_months`,
`last_service_date`, `next_due_date`, state machine (`draft/active/paused/cancelled`),
`booking_token` (unique), `last_reminder_band`, `booking_repair_id`. `roll_next_due_date()`
advances the cycle correctly via `relativedelta`.
- Reminder cron `cron_send_due_reminders` — daily, **30/7/1day** bands, perband dedup, queued
branded email `email_template_maintenance_due_reminder` with the tokenized link.
- Public booking controller `/repairs/maintenance/book/<token>``auth='public'`, tokenvalidated,
alreadybooked guard, thanks page.
- `create_repair_from_booking()` — spawns a `repair.order` (`x_fc_intake_source='client_portal'`),
links `x_fc_maintenance_contract_id`, dedups.
- **Rollforward** on technician task completion ([`technician_task.py:88`](../../../fusion_repairs/models/technician_task.py:88)): when a `task_type='maintenance'` task → `status='completed'`, sets `last_service_date`, calls `roll_next_due_date()`, posts chatter. **This is the recurring loop.**
- Prepaid **serviceplan subscriptions** (`fusion.repair.service.plan.subscription`) wired to
`sale.order.action_confirm()` + visit burn engine (revenue primitive; optional here).
- **Rate card** (`fusion.repair.callout.rate`, standard vs `lift_elevating`), `repair.order.x_fc_quote_total`.
- **Equipment category taxonomy** (`fusion.repair.product.category`): stairlift / porch_lift /
lift_chair flagged `equipment_class=lift_elevating`, `safety_critical=True`.
- **Inspection certificate** (`fusion.repair.inspection.certificate`, M1 — Done): PDF + expiry cron.
- Visitreport wizard (signature, parts, labour timer).
- `product.template.x_fc_maintenance_interval_months` (exists, [product_template.py:23](../../../fusion_repairs/models/product_template.py:23)).
- `fusion_tasks` availability engine: [`_find_next_available_slot(tech_id, date, ...)`](../../../fusion_tasks/models/technician_task.py:544) and [`_get_available_gaps(tech_id, date, ...)`](../../../fusion_tasks/models/technician_task.py:664) — **routeaware** (tech start address + geocoding + travel). Tech skills on `res.users.x_fc_repair_skills`.
### 3.2 The 4 gaps this spec closes
1. **Contract autocreation trigger is dead code**`_spawn_maintenance_contracts()` is defined on
`sale.order` ([maintenance_contract.py:198](../../../fusion_repairs/models/maintenance_contract.py:198)) but **never called**. No `action_confirm` override invokes it → no contracts exist today.
2. **No real booking** — the booking page is a bare `<input type="date">` ("a team member will call
to confirm"); no availability, no slots, no calendar/task. **This is the main new build.**
3. **No cost shown to the client** anywhere (email or booking page).
4. **No auto techtask creation, no structured maintenance log, no officefollowup crons**
(`ir.config_parameter` toggles exist; no cron/Python).
### 3.3 Installbase sizing (Westin live, 20260602)
- Serial numbers are captured **~only on real equipment** (parts have 0 serials) → `x_fc_serial_number`
is a defacto "trackable unit" marker and the natural **idempotency key**.
- ADPside base ≈ **138 serialtracked units / ~136 customers** (walkers 68, wheelchairs 45, power
bases 7, scooters 4, +14 nodevicetype). Funders: adp 109, direct_private 13, adp_odsp 10,
march_of_dimes 7. Deliveries 202210 → 202605.
- **Lifts (sized 20260602; namebased, approximate)** — a LARGE base in Westin's Odoo: stair lifts
~254 customers (416 lines incl. accessories), porch/VPL ~30 customers (75 lines), lift chairs ~41
customers (47 lines) — real products (Access BDD, Handicare, Serenity VPL, Pride VivaLift). **But lift
serial coverage is ~0** (12/416 stairlift lines, 0 VPL, 2 liftchair). So the serialasunitkey
approach that works for ADP wheelchairs **does NOT work for lifts** — lifts must be keyed by
(partner + baseunit product + sale line), excluding accessory lines (curves, rails, remotes, charging
stations, rentals). This splits the backfill into two regimes (§6.2).
- Two backfill data gaps: 14 units have no device_type (need product/manual category); nonADP units
lack `x_fc_adp_delivery_date` (need an invoice/orderdate fallback anchor).
## 4. Architecture
Extend `fusion_repairs`. No new module, no new toplevel dependency for the core flow (booking uses
`fusion_tasks`, already a hard dep; pricing/Poynt already deps). The optional `fusion_claims` read
for the wheelchair backfill is a **soft** dependency (guarded `if 'fusion.claims' model present`),
so `fusion_repairs` still installs/testruns without `fusion_claims` on local dev.
Reuse map: contract engine (extend), `fusion.technician.task` (booking target + availability +
rollforward), `repair.order` (visit container/pricing/Poynt), inspection certificate (lift
compliance), visitreport wizard (extend with checklist), branded email pattern, rate card.
## 5. Data model
All new fields `x_fc_`, Canadian English labels, Monetary = `$` + `currency_id`.
### 5.1 Maintenance policy — on `fusion.repair.product.category` ("per equipment type")
- `x_fc_maintenance_enabled` (Boolean) — is this category maintainable?
- `x_fc_maintenance_interval_months` (Integer) — default cadence (16+).
- `x_fc_maintenance_fee` (Monetary, `currency_id`) — the **flat fee** shown to the client.
- `x_fc_maintenance_skill_id` — the technician skill the booking matches on (maps to
`res.users.x_fc_repair_skills`). **If skills are already categorybased** (a tech's
`x_fc_repair_skills` are equipment categories), drop this field and simply match technicians whose
skills include *this* category — confirm the skills representation before modelling (§15).
- `x_fc_maintenance_service_product_id` (M2O `product.product`, optional) — the service product used
when drafting the priced invoice/SO line; falls back to a generic "Maintenance visit" product.
**Perproduct override:** `product.template.x_fc_maintenance_interval_months` (exists) +
new `product.template.x_fc_maintenance_fee` (Monetary, optional). Resolution order at contract
creation: product override → category policy.
### 5.2 Extend `fusion.repair.maintenance.contract`
- `x_fc_maintenance_fee` (Monetary) — resolved price snapshot, shown to client.
- `x_fc_source` (Selection: `sale` / `backfill` / `claims` / `manual`).
- `x_fc_source_sale_line_id` (M2O `sale.order.line`) — provenance + idempotency.
- `x_fc_device_serial` (Char, indexed) — idempotency key (esp. for claims/backfill where no lot).
- `x_fc_policy_category_id` (M2O `fusion.repair.product.category`).
- Constraint: at most one **active** contract per `(x_fc_device_serial)` (or per source sale line
when serial absent) — declarative `models.Constraint` / partial `models.Index`.
### 5.3 New `fusion.repair.maintenance.visit` (the log)
A structured, queryable pervisit record — *not* buried in chatter.
- `contract_id` (M2O, required), `technician_task_id` (M2O `fusion.technician.task`),
`repair_order_id` (M2O `repair.order`, the container), `partner_id`, `product_id`, `lot_id`.
- `visit_date`, `technician_id` (res.users), `state` (`scheduled/in_progress/done/no_show/cancelled`).
- `checklist_line_ids` (O2M to `fusion.repair.maintenance.checklist.line`: label, result
`pass/fail/na`, note) — items seeded **per equipment category** (lift checklist ≠ wheelchair
checklist).
- `findings` (Html, `Markup()`), `parts_note`, `x_fc_fee` (Monetary), `signature` (Binary),
`inspection_certificate_id` (M2O — set for `safety_critical` categories).
- "log/history" view = the list of visits per contract/unit (smart button on contract + partner).
## 6. Enrollment — two paths
### 6.1 Path A — new sales (fix the dead trigger)
Override `sale.order.action_confirm()` to call `_spawn_maintenance_contracts()` (reuse the existing
method; fix + wire it). For each confirmed line whose product/category has
`x_fc_maintenance_enabled` and a serial/lot:
- Create one `active` contract per unit (respect quantity), `x_fc_source='sale'`,
`x_fc_source_sale_line_id` set, serial captured.
- `next_due_date = (delivery/commitment date or date_order) + interval` (fallback chain handles
nonADP units lacking a delivery date).
- Resolve + snapshot `x_fc_maintenance_fee`.
- **Idempotent**: skip if an active contract already exists for the serial / sale line.
### 6.2 Path B — backfill existing install base (onetime wizard, idempotent)
`fusion.repair.maintenance.backfill.wizard`:
- **Scan** historical `sale.order.line` for products whose category/product is maintenanceenabled and
were delivered. **Two unitidentity regimes**, because lifts carry no serials (§3.3):
- **Serialtracked** (ADP wheelchairs/power chairs, via the `fusion_claims` serial/`device_type` data
— soft dep, guarded; map ADP `device_type` → maintenance category): require a serial, **dedup by serial**.
- **Nonserial** (lifts — stair/porch/VPL/liftchair): do **NOT** require a serial. One contract per
**baseunit line**, **dedup by (partner + maintainable product + source sale line)**. The perproduct
`x_fc_maintenance_enabled` flag is what includes base units and **excludes accessory lines** (curves,
rails, remotes, charging stations, rentals) — only the lift itself gets a contract, not its addons.
- **Stagger** the first `next_due_date` across a configurable window (e.g. spread overdue units over
N weeks) so years of equipment don't all email on day one.
- **Dryrun first**: produce a report (counts by category, # new vs alreadyenrolled, # skipped for
missing serial/date, the stagger schedule). Nothing is created or emailed until the operator
approves and runs "Execute".
- Anchor fallback for units with no delivery date: invoice date → order date → today.
## 7. Booking flow (the main build)
### 7.1 Client selfserve (no login)
1. Reminder email (existing branded template, **+ fee line added**) → tokenized link.
2. Public slotpicker page (extend the existing `/repairs/maintenance/book/<token>` route; replace
the date input). The page:
- Resolves the contract from the token; shows unit + **flat fee** ("$X + applicable tax").
- Computes candidate technicians = users whose `x_fc_repair_skills` include the policy's
`x_fc_maintenance_skill_id`.
- Calls `fusion_tasks` `_get_available_gaps` / `_find_next_available_slot` per candidate tech over
the next ~23 weeks, ranked by **proximity** to the client address → presents a short list of
real open slots (date + window + implied tech).
3. Client picks a slot → POST confirm:
- **Revalidate** the slot is still free (gap check) — if taken/expired, rerender slots with a
gentle notice (prevents doublebooking).
- Create a `fusion.technician.task` (`task_type='maintenance'`) on that slot, **assigned to the
qualified tech** (autoassignment by availability+skill), linked to the contract.
- Spawn/link the maintenancetype `repair.order` (container) + the `fusion.repair.maintenance.visit`
(state `scheduled`, checklist seeded from the category).
- Send the branded confirmation email (date/window/tech, fee, what to expect).
- Set `booking_repair_id` (dedup).
4. **Noslot fallback:** if no qualified tech/slot in range → show "request a callback" → create an
office activity. Never a dead end.
### 7.2 Office books on the client's behalf
- A **"Book maintenance"** action on the `fusion.repair.maintenance.contract` form opens the same
slotpicker logic in the backend (office books while on the phone).
- The existing dispatch board remains available for manual scheduling/override.
### 7.3 Token security fix
On `roll_next_due_date()`, **regenerate `booking_token`** (currently it is not regenerated, so an
old link stays valid across cycles). Old token → friendly "link expired" page.
## 8. Cost & revenue
- The **flat fee** (`x_fc_maintenance_fee`) is shown in **both** the reminder email and the
slotpicker page, Canadian English, `$` + tax note.
- On booking, draft a priced line (SO/invoice) using `x_fc_maintenance_service_product_id` (or the
generic visit product) at the contract's fee. Payment options: **payatdoor via `fusion_poynt`**
(existing `action_collect_payment` on the repair) or invoice after the visit.
- Recurring revenue = one priced visit per cycle; the rollforward arms the next cycle automatically.
(Prepaid annual plan upsell via the existing subscription engine is out of v1 — §11.)
## 9. Maintenance log & the recurring loop
- The technician fills the visit via the **extended visitreport wizard** (existing tool) — checklist
results, findings, parts, signature — which writes the `fusion.repair.maintenance.visit` record.
- For `safety_critical` categories (lifts), completing the visit **issues an inspection certificate**
(reuse M1) and links it on the visit — the log doubles as compliance proof.
- On task `status='completed'` → existing **rollforward**: `last_service_date=today`,
`next_due_date += interval`, reset `last_reminder_band`, **regenerate token**, visit → `done`.
- Next cycle's reminder fires automatically when `next_due_date` reenters the 30day band.
## 10. Office followup crons (togglegated, exist as config only today)
- **Unbooked**: reminder sent, no booking after N days → office call activity on the contract.
- **Overdue**: `next_due_date` passed with no completed visit in the cycle → escalation activity.
- Driven by the existing `ir.config_parameter` toggles in `data/ir_config_parameter_data.xml`.
- Perrow **savepoint** isolation inside the cron loop (no `cr.commit()` in tests — CLAUDE.md #14).
## 11. Out of scope (v1 — YAGNI)
- SMS reminders / twoway SMS booking (needs `fusion_ringcentral`).
- Loggedin `/my/equipment` client portal (X5).
- Prepaid annual maintenanceplan autoupsell at booking.
- Full multistop route optimization / batching (we use pertech availability + proximity ranking,
not a global optimizer).
- ADP funder rebilling of maintenance (maintenance is privatepay flat fee in v1).
## 12. Error handling & edge cases
- **Doublebooking:** revalidate the gap at confirm; lose the race → reshow slots.
- **Token:** percycle regeneration; invalid/expired/alreadybooked → friendly pages (exist, extend).
- **No qualified tech / no slots:** callback fallback, not an error page.
- **Backfill:** dryrun + report; strict serial dedup; stagger; fallback anchor chain; never email on
dryrun.
- **Missing data:** units with no device_type/category → excluded from autobackfill, listed in the
report for manual enrollment.
- **Audit on failure paths** (if any "booking failed" row is written in an `except`): use a separate
`self.env.registry.cursor()` so it survives rollback (CLAUDE.md audit rule).
- **`message_post` HTML** bodies wrapped in `Markup()` (CLAUDE.md).
## 13. Testing
`fusion_repairs/tests/` (none exist today). Local dev is **Community** and — because we chose
`fusion_tasks` over Enterprise `appointment` — the **entire feature is Communitytestable** on
`odoo-modsdev`. `TransactionCase` coverage:
- Contract spawn on `sale.order` confirm (enabled vs disabled category; quantity; idempotency).
- Backfill wizard: **tworegime dedup** (serial for wheelchairs; partner+product+line for lifts), accessoryline exclusion, stagger, dryrun produces no records, anchor fallback.
- Booking: slot list comes from real gaps; confirm creates task+repair+visit; **doublebook guard**;
noslot fallback.
- Rollforward on completion: dates advance, band reset, **token regenerated**, visit → done.
- Crons: reminder bands; unbooked/overdue followups (savepoint isolation).
- Run: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0`.
## 14. Deployment & configuration
1. Land on local dev, full E2E + tests green.
2. **Deploy `fusion_repairs` to Westin** (`odoo-westin` / `westin-v19`) — the accepted bigger lift
(first production deploy of fusion_repairs; verify ratecard numbers, ACLs, asset bundles).
3. **Configure** maintainable categories: `x_fc_maintenance_enabled`, interval, fee, skill, service
product — for lifts (stairlift/porch/lift chair) + power & manual wheelchairs.
4. Ensure technicians have `x_fc_repair_skills` + start addresses (for availability/routing).
5. Run the **backfill wizard dryrun → review report → execute** (staggered).
6. Watch the first reminder/booking cycle; confirm emails, slots, task creation, completion → roll.
## 15. Open items to verify at implementation (rule #1 — read live source)
- Exact representation of tech skills (`res.users.x_fc_repair_skills`) and how a category's required
skill maps to it (Selection vs M2O vs tag) — read fusion_repairs/fusion_tasks before modelling
`x_fc_maintenance_skill_id`.
- Signatures of `_find_next_available_slot` / `_get_available_gaps` (params, return shape, working
hours source) and whether they already account for travel windows.
- The visitreport wizard's current fields/flow before extending it with the checklist.
- The inspectioncertificate issue API (how M1 creates a certificate) for the lift link.
- **Lift base sized** (§3.3): ~254 stairlift + ~30 porch/VPL + ~41 liftchair customers, but ~0 serials.
Still to verify: which exact products are **base units vs accessories** (so `x_fc_maintenance_enabled`
lands on base units only), plus the lift interval/fee per category. Lift products aren't yet tagged
with `fusion_repairs` categories on Westin (module not deployed there) — categorization is a deploy step.
- `fusion_claims` device_type → maintenancecategory mapping table for the wheelchair backfill.
## 16. Build sequence (for the implementation plan)
1. **Policy + fee data model** (category fields, product override, contract extensions, constraints).
2. **Path A trigger** (wire `_spawn_maintenance_contracts` into `action_confirm`, fee resolution, anchor fallback) + tests.
3. **Cost in email** (add fee to the reminder template).
4. **Technicianaware booking** (slotpicker page + controller on `fusion_tasks` availability; task/repair/visit creation; doublebook guard; office action; token regen) + tests — the largest unit.
5. **Maintenance visit log + checklist** (model, percategory seed, visitreportwizard extension, inspectioncert link) + tests.
6. **Backfill wizard** (scan/dedup/stagger/dryrun; fusion_claims soft bridge) + tests.
7. **Office followup crons** (unbooked/overdue) + tests.
8. **Deploy + configure + backfill** on Westin.

View File

@@ -1832,3 +1832,41 @@ When adding a new admin config, drop it into the right Configuration folder:
- Generic value lists → Reference Data - Generic value lists → Reference Data
Don't add new top-level Configuration entries (siblings of the 7 folders) unless absolutely necessary — Settings is the only one allowed. Don't add new top-level Configuration entries (siblings of the 7 folders) unless absolutely necessary — Settings is the only one allowed.
---
## Partial Order Handling — parts fanning across stages (shipped 2026-06-02)
A 50-part job can have parts at several stages at once (10 Masking, 20 Plating, 20 Baking). The data layer always supported this (`fp.job.step.qty_at_step` = live parked count, computed from `fp.job.step.move` rows); 2026-06-02 made it **visible and operable**. Spec: [`docs/superpowers/specs/2026-06-02-shopfloor-partial-order-handling-design.md`](docs/superpowers/specs/2026-06-02-shopfloor-partial-order-handling-design.md). Versions: `fusion_plating 19.0.22.2.0`, `fusion_plating_jobs 19.0.11.6.0`, `fusion_plating_shopfloor 19.0.36.2.0`. Tracking model = **fluid quantities per stage** for normal flow + existing hold/scrap/rework records for exceptions (no new model, no migration). Close behaviour = **wait to reconverge** (the lifecycle is unchanged; the diverged subset keeps the job open via the existing `qty_done + qty_scrapped == qty` gate).
**Durable gotchas (non-obvious):**
1. **The plant kanban emits one card PER (job, stage), keyed by a composite `"{job_id}:{area}"`** — NOT one card per job. `cards` is a dict of composite-key → presence payload; a split job lists its key in several `columns[].card_ids`. See `_job_presences` / `_render_presence` in `plant_kanban.py`. A job with all parts at one stage yields exactly ONE presence (identical to the old board). The PRIMARY presence (active-step column) keeps the full job-level `card_state`; SECONDARY presences derive a simpler state from their own focus step (`_secondary_card_state`). Anything reading the board payload must handle composite keys + multi-column jobs. **A presence is emitted ONLY where parts physically are (`qty_at_step > 0`, incl. the first-active seed) OR a step is `in_progress`/`paused` — NEVER for a merely `ready`/`pending` future step.** These recipes seed EVERY downstream step to `ready` at job creation (not `pending`), so keying presence off `ready` made one job show in every not-yet-started stage at once (WO-30061 bug, fixed 2026-06-02). The old single-card board masked this because `active_step_id` picked just one. Strict sequential progress falls out for free: the `qty_at_step` seed always sits on the lowest-sequence non-terminal step and advances as each completes — so don't add `ready` back to the presence condition.
2. **`fp.job.step` has NO `qty_done` / `qty_scrapped` fields.** Those live on `fp.job`. The Move controller previously read `from_step.qty_done - from_step.qty_scrapped` for "available to move" → always 0 → the partial-move path was effectively dead. The source of truth for "parts parked here" is **`qty_at_step`** (move preview/commit + rack moves all read it now). Never reintroduce `step.qty_done`.
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.
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream.
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
**Verification:** the plating modules can't be installed on the local Community dev DB (missing enterprise deps — same reason `fusion_plating` shows `installed=0` in `modsdev`/`fusion-dev`). Static checks done: pyflakes (Python), lxml parse (XML), `node --check` as `.mjs` (JS — `node --check` on a `.js` errors with "Cannot use import statement outside a module"; copy to `/tmp/x.mjs` first). Dynamic tests + browser check require an installed env (entech / odoo-trial).
---
## Dark-mode SCSS gotchas — shop-floor dialogs/components (fixed 2026-06-02)
Operators reported invisible (dark-on-dark) text in the workspace + "Cannot Finish Step" dialog under Odoo dark mode. Root causes + the rules:
1. **Odoo's compiled backend CSS does NOT define the Bootstrap colour custom-properties — `var(--bs-body-color)`, `var(--bs-secondary-color)`, `var(--bs-tertiary-bg)`, `var(--bs-body-bg)` are REFERENCED but never DEFINED (verified 2026-06-02: 0 definitions for `--bs-body-color`/`--bs-secondary-color` in the live `web.assets_backend` text).** So **any `color: var(--bs-body-color, #hex)` resolves to the `#hex` fallback in BOTH light and dark mode** — a dark hex → invisible on a dark surface. (`var(--text-secondary, …)` is even worse — that var name is entirely made-up.) Odoo themes the backend via **runtime `[data-bs-theme="dark"]`** (Bootstrap 5.3) + SCSS literals, NOT via those CSS vars, and NOT via `prefers-color-scheme`. Do NOT colour custom text with `var(--bs-*)`. **Correct, verified options:**
- **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.
3. **Confirmed-present, dark-aware Odoo vars to reach for:** `--bs-body-color` (primary text), `--bs-secondary-color` (muted text), `--bs-body-bg` / `--bs-tertiary-bg` (surfaces), `--bs-border-color`. The deliberate color-coded plant-card status chips (`_plant_card.scss` `.kind-*` / `.tag-*`) are light-bg + dark-text (readable in both modes, just bright on a dark card) — intentionally left as a color-coded set.

View File

@@ -0,0 +1,173 @@
# Shop Floor — Partial Order Handling (design)
- **Date:** 2026-06-02
- **Status:** Approved (design), pending implementation plan
- **Modules touched:** `fusion_plating_shopfloor`, `fusion_plating_jobs`, `fusion_plating` (core step model)
- **Author context:** Nexa Systems / EN Technologies (entech), Odoo 19
## Problem
A plating job runs 50 parts, but parts physically fan out across stages — 10 at Masking, 20 at Plating, 20 at Baking — because the shop processes in racks/waves through tank-limited stations. Today the Shop Floor board collapses each job to **one card in one column** (the single `active_step_id.area_kind`), so an operator standing at Baking has no way to see "10 of this job's parts are here, waiting for me." Operators need to handle the spread-out parts through the different stages, and it must be **easy** (minimal typing, minimal guessing) and **production-ready**.
## Goals
- Operators can **see** a job's parts at every stage where they physically are.
- Operators can **advance** a subset to the next stage with near-zero friction.
- Failed/held/rework subsets are tracked and visible (not silently lost).
- The change is **additive** — it must not redesign the board, the quantity model, the workspace entry contract, or the close/cert/ship/invoice lifecycle.
## Non-goals (explicitly out of scope for v1)
- **Partial shipping** — shipping the good parts now and the rest later (split CoCs, split deliveries, partial invoicing). The job reconverges and ships once.
- **Named sub-lots** — persistent per-group identity/labels for interchangeable parts.
- **Manager-initiated job split** — closing a job at a shipped quantity and spawning a follow-on job for the remainder. Notable as a possible future add-on; operators never touch it.
- Multi-cert / multi-delivery per job; per-delivery quantity tracking.
---
## Current-state baseline (verified in code)
**The data layer already supports partial quantities.**
- `fp.job.step.qty_at_step` — live "parts parked here" = `sum(incoming moves) sum(outgoing moves)`, with a first-step seed (the earliest non-terminal step implicitly holds full `job.qty` before any move). Compute at `fusion_plating/models/fp_job_step.py::_compute_qty_at_step`.
- `fp.job.step.move` (`fusion_plating/models/fp_job_step_move.py`) — chain-of-custody row per move; `qty_moved` already lets an operator move a subset; `transfer_type` ∈ {step, hold, scrap, rework, split, return}.
- `move_parts_commit` (`fusion_plating_shopfloor/controllers/move_controller.py`) already moves a subset and advances `qty_at_step_start/finish`.
- `button_finish` already refuses to close a stage while parts are parked there with a downstream step waiting (`fusion_plating/models/fp_job_step.py`).
**The gap is visibility + interaction.**
- `fp.job.active_step_id` picks **one** step by priority (in_progress > paused > ready > pending) — `fusion_plating_jobs/models/fp_job.py::_compute_active_step_id`.
- The plant kanban places the whole job in the **one** column of that step — `fusion_plating_shopfloor/controllers/plant_kanban.py::_resolve_card_area` / `_render_card`; the board payload is `cards` (dict keyed by `str(job.id)`) + `columns[].card_ids` (list of job ids); OWL renders one `FpPlantCard` per id (`plant_kanban.xml`, `components/plant_card.js`).
- The Move dialog (`move_parts_dialog.{js,xml}`) exposes Transfer Type (6 options) and To Location (6 options) as always-visible dropdowns and uses a raw numeric input for qty.
**The close lifecycle assumes one job = one quantity = one CoC = one delivery.**
- `button_mark_done` (`fusion_plating_jobs/models/fp_job.py`) gates on step-completion, bake, `qty_done + qty_scrapped == qty`, receiving reconciliation, and QC, then calls `_fp_create_delivery()` + `_fp_create_certificates()` (one each).
- Auto-advance: last step finished → `awaiting_cert` (if a cert is required) or `awaiting_ship`; cert issue advances `awaiting_cert → awaiting_ship`; cert void regresses. There is an order-level "ship together" constraint (`_fp_order_ship_state`).
- No partial-shipment infrastructure exists anywhere.
---
## Decisions
| Fork | Decision |
|---|---|
| Tracking model | **C — fluid quantities per stage + existing records for exceptions.** Normal flow uses `qty_at_step`; failed/held/rework subsets ride the existing hold/scrap/rework records. No new core model. |
| Board representation | **Option 2 — a card per stage-presence.** A job appears as a card in every stage where it has parts; composite `job:area` card keys. Unsplit jobs render identically to today. |
| Operator move interaction | **Easy-advance.** One intent-named "Send to [next] →" action with everything defaulted; steppers / rack-tap instead of a keyboard; Hold/Scrap/Rework as distinct buttons. |
| Downstream "light-up" | **Auto-ready on arrival + qty-aware predecessor gate + auto-finish source on drain.** No auto-start (labour accuracy). |
| Close behaviour | **Option B — wait to reconverge.** The close/cert/ship/invoice lifecycle is unchanged; the diverged subset keeps the job open via the existing reconciliation gate. |
---
## Detailed design
### A. Data model
No new model and no new core fields are required. The feature reuses:
- `fp.job.step.qty_at_step` (live parked count) — already the source of truth.
- `fp.job.step.move` + `transfer_type` — already records advance/hold/scrap/rework.
- `fusion.plating.quality.hold` — already represents "N parts of this job are held" (the tracked exception group).
### B. Board — card per stage-presence
**Backend (`plant_kanban.py`).** Replace the "one area per job" bucketing with per-stage presences. For each in-flight job:
1. Group the job's **non-terminal** steps by `area_kind` (note: many steps can share an area, e.g. all wet-line steps roll into `plating` per the existing column map).
2. For each area, compute:
- `qty_here` = sum of `qty_at_step` across that area's non-terminal steps.
- `focus_step_id` = the most-actionable step in the area (in_progress > paused > ready > pending) — used for the tap target and the per-presence state.
3. **Emit a presence** for an area when `qty_here > 0` **or** any step in the area is in_progress/paused/ready (a started-but-drained stage still shows until finished).
4. **Exception presences:** a quality hold on N parts surfaces as a flagged presence ("🔴 N on hold") in the stage it is associated with. *Linkage detail* (hold→step/area vs. hold→job + area inference) is finalized in the plan after reading the hold model; fallback is to attach the held indicator to the job's furthest-along presence. Rework re-entering an earlier stage shows as a normal presence there (optionally flagged "rework"). Scrap is not a presence (counted in `qty_scrapped`, shown in job totals only).
**Payload schema.** `cards` becomes a dict keyed by composite `"{job_id}:{area}"`; `columns[].card_ids` lists composite keys; a split job lists one key in each occupied column. Each presence payload is the existing card payload **plus**: `area_kind`, `qty_here`, `job_qty`, `focus_step_id`, and a per-presence `card_state` / `state_chip` / `operator` / `step_name` derived from that area's focus step (not the job's global `active_step_id`). Reuse the existing helpers (`_state_chip`, `_compute_tags`, `_due_label`, `_icons`).
- The job-level `card_state` / `active_step_id` computes stay **unchanged** — they still drive server-side filters and KPI counts.
- **KPIs** dedupe by `job_id` (count distinct jobs, not presences).
- **mini_timeline** on every presence still shows the **whole-job** spread, so the big picture is visible from any stage.
**Frontend (`plant_kanban.xml`, `components/plant_card.js`).**
- The render loop (`t-foreach columns → card_ids → FpPlantCard`) is **unchanged**`t-key="card_id"` already works with composite keys.
- `FpPlantCard` gains a "**{qty_here} of {job_qty}**" line; `onCardClick` passes `focus_step_id` into the workspace (the workspace already accepts `focus_step_id` — see the FP-STEP scan path in `plant_kanban.js`).
- `filteredCardIds` is unchanged (it filters on card payload fields; each presence carries the same searchable fields, so all presences of a matching job show).
**Unsplit invariant.** A job with all parts at one stage produces exactly one presence → one card in one column → byte-for-byte identical to today. Multi-card behaviour only activates on an actual split.
### C. Operator flow — easy advance
**Primary action: "Send to [next stage] →".** Surfaced on the stage presence (card/workspace). Opens a slim confirm pre-set to:
- `to_step` = next step in recipe sequence (no guessing).
- `qty` = all parked here (`qty_at_step`); adjustable with **± steppers + an "All" preset** — the keyboard never opens for the common case.
- `transfer_type` = `step` (hidden); `to_location` = `global` (hidden behind **More options**); `to_tank` = recipe default (existing behaviour).
- Compliance prompts render only when the recipe author marked them (unchanged), using pickers, not free text.
**Racked parts:** when parts are on racks, advancing is **rack-granular** — tap the rack(s) to send, moving that rack's count atomically via the existing **Move Rack** flow. No quantity typed at all.
**Exceptions get their own intent-named buttons** (replacing the Transfer Type dropdown for everyday use):
- **Hold** — pick qty + reason from a picker → reuses the existing hold composer → the subset becomes the tracked held group; the rest stay put.
- **Scrap** — qty + reason → counted in `qty_scrapped`.
- **Rework** — qty + destination earlier stage → a `transfer_type='rework'` move.
The full generic Move dialog remains available behind "More options" — we slim the default path, we don't delete capability.
### D. State machine — the invisible "light-up"
Three small, additive behaviours so operators never manage stage state manually:
1. **Auto-ready on arrival.** In `_do_move_parts_commit` (and `_do_move_rack_commit`): after the move + counter advance, if `to_step.state == 'pending'`, set it to `ready`. Never downgrade a step. Result: the receiving operator's column immediately shows a "{qty} of {job_qty} · Ready to start" card with no action by anyone.
2. **Qty-aware predecessor gate.** A step that has **real parts parked** (an incoming move with `from_step_id != step` and `qty_moved > 0`) is startable regardless of whether upstream steps are fully done. Applied consistently in `_fp_should_block_predecessors` (used by both `button_start` and the Move dialog's `_blockers_for_move`). Rationale: once parts physically arrive, the predecessor lock is moot.
3. **Auto-finish the source on drain-to-zero.** When a move drains `from_step.qty_at_step` to 0 and the step is in_progress with no remaining work, finish it via the existing finish path (generalize the `action_complete_one_to_next` drain→finish pattern to bulk moves). One fewer tap.
Deliberately **no auto-start** of the receiving step — `button_start` stays an explicit tap because it begins the labour timer (keeps cost/time accurate and avoids the phantom-timer problem the S16 cron already fights).
**Correctness fix.** The move preview currently derives availability from `from_step.qty_done from_step.qty_scrapped`; change it to read `qty_at_step` (the live parked count shown on the card) so the pre-filled number **always matches what the operator sees**.
### E. Close — wait to reconverge (Option B)
The close/cert/ship/invoice lifecycle is **unchanged**:
- `button_mark_done` gates, `_fp_create_certificates` (one CoC), `_fp_create_delivery` (one delivery), and the `awaiting_cert`/`awaiting_ship` transitions stay as-is.
- The diverged subset keeps the job open **for free**: with parts in hold/rework, `qty_done + qty_scrapped` cannot equal `qty`, so the existing reconciliation gate simply won't let the job close until those parts resolve (reworked → done, or scrapped → counted). This is the correct behaviour, already enforced.
- Normal jobs reconverge at Final Inspection (all parts back in one count) and close once.
**Hardening (additive).** At close, `qty_done` auto-fill should derive from the quantity that actually **completed the final runnable step** (via the last step's `qty_at_step_finish` / move chain), not assume `job.qty`. Keep the existing reconciliation gate — just feed it an honest number when parts fanned out.
### F. Reconciliation invariant
At any time: `job.qty = (parts parked across all stages) + (on hold) + (in rework) + (scrapped) + (completed/shipped)`. The board surfaces the first three as presences; `qty_scrapped` and the final count feed the existing close gate `qty_done + qty_scrapped == qty`.
---
## Blast radius
**Changes**
- `plant_kanban.py` — per-stage presence rendering + composite keys + KPI dedupe (localized to `_render_card` + the bucketing loop; reuses all existing chip/tag/icon helpers).
- `components/plant_card.js` + `plant_kanban.xml` — "{qty} of {job_qty}" line; tap passes `focus_step_id` (~a few lines).
- `move_parts_dialog.{js,xml}` — slim "Advance" default (steppers, "All", hidden advanced fields); Hold/Scrap/Rework intent buttons; full dialog behind "More options".
- `move_controller.py` — auto-ready on arrival; auto-finish on drain; preview availability/pre-fill from `qty_at_step`.
- `fp_job_step.py` — qty-aware predecessor gate; bulk drain→finish helper.
- `fp_job.py``qty_done` derivation at close (hardening only).
**Does NOT change**
- The quantity model (`qty_at_step`, `fp.job.step.move`).
- The OWL component tree (FpTabletLock → header → board → columns → FpPlantCard → FpMiniTimeline), polling, filters, search, station pairing, QR.
- The Job Workspace entry contract (`focus_step_id` already supported).
- Holds / certs / bake windows / QC.
- The close → cert → ship → invoice lifecycle (`button_mark_done`, `_fp_create_certificates`, `_fp_create_delivery`, awaiting_cert/awaiting_ship, order "ship together").
---
## Edge cases
- **No recipe / no steps:** orphan fallback unchanged (Receiving column).
- **Multiple steps in one area** (wet-line → plating): presence aggregates `qty_here` across them; `focus_step_id` is the most-actionable.
- **First-step seed:** before any move, the whole qty sits at the first stage → one presence = today's single card.
- **Recombination:** 30 + 20 both reaching Baking simply read as `qty_here = 50` at that stage (fluid quantities merge automatically).
- **Held parts with no step linkage:** held indicator attaches to the job's furthest-along presence (plan-time detail).
- **Search match:** every presence of a matching job shows across its columns (each carries the same searchable fields).
## Testing
- **Unit:** `qty_at_step` across a multi-stage split; qty-aware predecessor (parts-present → startable); auto-ready pending→ready on move; auto-finish source on drain-to-zero; `qty_done` derivation at close; reconciliation gate still fires with held parts.
- **Integration:** board emits N presences for a split job and exactly 1 for an unsplit job; KPIs dedupe by job; tapping a presence opens the workspace on the right step.
- **Persona walk:** operator finishes a subset at Plating → taps Send → receiver sees a "Ready" card at Baking with the right qty; 5 parts go on Hold → job stays open until resolved → reworked/closed → one cert, one delivery.
## Deployment
Per-module `__manifest__.py` version bumps so the SCSS/asset bundle busts and any data reloads. Entech is native Odoo (LXC 111, DB `admin`) — standard `-u fusion_plating_shopfloor,fusion_plating_jobs,fusion_plating` upgrade. No data migration required (no new persistent fields; additive behaviours only).

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.22.1.0', 'version': '19.0.22.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.11.5.0', 'version': '19.0.11.6.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -2074,11 +2074,27 @@ class FpJob(models.Model):
# the operator reconciles by hand. Mirrors the receiving # the operator reconciles by hand. Mirrors the receiving
# `_update_job_qty_received` pattern: server fills the # `_update_job_qty_received` pattern: server fills the
# obvious default, operator owns the edge cases. # obvious default, operator owns the edge cases.
if (not job.qty_done and not job.qty_scrapped # Partial-order handling (2026-06-02): surface scrap that
# was recorded through the Move log (transfer_type='scrap')
# into qty_scrapped, so the reconciliation + cert qty stay
# honest even when scrap was done from the tablet Move
# dialog rather than the qty_scrapped field. Only when the
# field hasn't been set by hand.
scrap_moves = job._fp_scrapped_via_moves()
if scrap_moves and not job.qty_scrapped:
job.qty_scrapped = scrap_moves
# Clean-close auto-fill: derive the good (done) count from
# what physically came in minus scrap, instead of blindly
# assuming the whole order completed (which over-counts when
# parts were scrapped mid-line). Skips when the operator
# already typed qty_done, or when visual rejects make the
# split non-obvious — then the gate below makes them
# reconcile by hand.
if (not job.qty_done
and not (job.qty_visual_inspection_rejects or 0) and not (job.qty_visual_inspection_rejects or 0)
and job.qty_received and job.qty_received
and abs(job.qty_received - job.qty) < 0.0001): and abs(job.qty_received - job.qty) < 0.0001):
job.qty_done = job.qty job.qty_done = job.qty - (job.qty_scrapped or 0)
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0) accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
if abs(accounted - job.qty) > 0.0001: if abs(accounted - job.qty) > 0.0001:
raise UserError(_( raise UserError(_(
@@ -2439,6 +2455,19 @@ class FpJob(models.Model):
fp_skip_step_gate=True, fp_skip_step_gate=True,
).button_mark_done() ).button_mark_done()
def _fp_scrapped_via_moves(self):
"""Total parts scrapped through the Move log (transfer_type=
'scrap') for this job. Lets button_mark_done's reconciliation
count scrap done via the tablet Move dialog, not just the
qty_scrapped field (partial-order handling, 2026-06-02)."""
self.ensure_one()
Move = self.env['fp.job.step.move']
moves = Move.sudo().search([
('job_id', '=', self.id),
('transfer_type', '=', 'scrap'),
])
return int(sum(m.qty_moved or 0 for m in moves))
def _fp_check_advance_post_shop(self): def _fp_check_advance_post_shop(self):
"""Auto-advance in_progress jobs whose recipe steps are all """Auto-advance in_progress jobs whose recipe steps are all
terminal. Called from fp.job.step.button_finish post-super(). terminal. Called from fp.job.step.button_finish post-super().

View File

@@ -54,12 +54,37 @@ class FpJobStep(models.Model):
# leak permissive behaviour through a related-field None. # leak permissive behaviour through a related-field None.
if not self.job_id: if not self.job_id:
return True return True
# Partial-flow short-circuit (2026-06-02 partial-order handling).
# Once REAL parts have physically arrived at this step (a move
# parked them here), the predecessor lock is moot — the parts are
# on the floor at this station, so the step is startable
# regardless of whether upstream steps are fully done. This is
# what lets a partial group "light up" the next stage while the
# rest of the batch is still being processed upstream. Single
# source of truth: every caller (can_start, blocker, button_start,
# the Move dialog's _blockers_for_move) inherits this behaviour.
if self._fp_has_real_incoming():
return False
recipe_seq = self.job_id.enforce_sequential recipe_seq = self.job_id.enforce_sequential
if recipe_seq: if recipe_seq:
return not self.parallel_start return not self.parallel_start
# Free-flow recipe — only the legacy per-step flag still gates. # Free-flow recipe — only the legacy per-step flag still gates.
return bool(self.requires_predecessor_done) return bool(self.requires_predecessor_done)
def _fp_has_real_incoming(self):
"""True when real parts have physically arrived at this step via
a move — an incoming move from a DIFFERENT step with qty_moved > 0.
Distinct from the qty_at_step first-step seed (a notional UI hint
with no backing move) and from self-loop measurement moves
(from_step == to_step, used by the Record Inputs wizard). Mirrors
the has_real_incoming test in core button_finish's qty gate.
"""
self.ensure_one()
return bool(self.incoming_move_ids.filtered(
lambda m: m.from_step_id != self and (m.qty_moved or 0) > 0
))
def _fp_has_unfinished_predecessors(self): def _fp_has_unfinished_predecessors(self):
"""True when an earlier-sequence step on the same job is not yet """True when an earlier-sequence step on the same job is not yet
in a terminal state. Composes with _fp_should_block_predecessors in a terminal state. Composes with _fp_should_block_predecessors
@@ -86,6 +111,10 @@ class FpJobStep(models.Model):
'job_id.enforce_sequential', 'job_id.enforce_sequential',
'job_id.step_ids.state', 'job_id.step_ids.state',
'job_id.step_ids.sequence', 'job_id.step_ids.sequence',
# Partial-flow: arriving parts clear the predecessor gate
# (_fp_has_real_incoming), so can_start must recompute on move.
'incoming_move_ids.qty_moved',
'incoming_move_ids.from_step_id',
) )
def _compute_can_start(self): def _compute_can_start(self):
for step in self: for step in self:
@@ -217,6 +246,9 @@ class FpJobStep(models.Model):
'state', 'sequence', 'parallel_start', 'requires_predecessor_done', 'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
'job_id.enforce_sequential', 'job_id.enforce_sequential',
'job_id.step_ids.state', 'job_id.step_ids.sequence', 'job_id.step_ids.state', 'job_id.step_ids.sequence',
# Partial-flow: arriving parts clear the predecessor gate.
'incoming_move_ids.qty_moved',
'incoming_move_ids.from_step_id',
) )
def _compute_blocker(self): def _compute_blocker(self):
for step in self: for step in self:
@@ -652,6 +684,42 @@ class FpJobStep(models.Model):
).sorted('sequence') ).sorted('sequence')
return candidates[:1] or self.env['fp.job.step'] return candidates[:1] or self.env['fp.job.step']
def _fp_try_autofinish_on_drain(self):
"""Best-effort auto-finish when a step has drained to zero parked
parts (2026-06-02 partial-order handling).
Called by the Move controller after a bulk move commits. When the
last parts leave an in_progress step it should close itself — one
fewer tap for the operator. But finishing runs the full gate chain
(required inputs, sign-off, contract review, receiving, and the
post-shop close gates on the last step). If any gate isn't
satisfied we must NOT fail the move that already succeeded — so we
swallow the UserError and leave the step in_progress for the
operator to finish manually (the board will show it "running, 0
here", which reads as "finish me").
Only fires for steps that had REAL incoming parts — never an
untouched first-step seed. Returns True if the step finished.
"""
self.ensure_one()
if self.state != 'in_progress':
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
# re-read so we see the just-committed outgoing move.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step != 0:
return False
try:
self.button_finish()
return True
except UserError:
# Gates still pending (missing prompts / sign-off / etc.) —
# leave the step in_progress for a manual finish. The move
# itself stands.
return False
def _fp_has_uncaptured_step_inputs(self): def _fp_has_uncaptured_step_inputs(self):
"""True when the recipe step has REQUIRED step_input prompts """True when the recipe step has REQUIRED step_input prompts
whose values haven't been recorded yet. whose values haven't been recorded yet.

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.36.1.1', 'version': '19.0.36.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
'description': """ 'description': """

View File

@@ -147,7 +147,12 @@ class FpTabletMoveController(http.Controller):
Step = request.env['fp.job.step'] Step = request.env['fp.job.step']
from_step = Step.browse(from_step_id) from_step = Step.browse(from_step_id)
to_step = Step.browse(to_step_id) to_step = Step.browse(to_step_id)
qty = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0) # Available-to-move = parts currently parked here (qty_at_step —
# the exact number the operator sees on the card). The old
# qty_done qty_scrapped read referenced step fields that don't
# exist on fp.job.step (always 0), which is why the move path was
# effectively unusable. See partial-order-handling design.
qty = from_step.qty_at_step or 0
return { return {
'ok': True, 'ok': True,
'qty_available': qty, 'qty_available': qty,
@@ -186,7 +191,7 @@ class FpTabletMoveController(http.Controller):
if hard: if hard:
raise UserError(hard[0]['message']) raise UserError(hard[0]['message'])
qty_avail = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0) qty_avail = from_step.qty_at_step or 0
move = Move.create({ move = Move.create({
'job_id': from_step.job_id.id, 'job_id': from_step.job_id.id,
'from_step_id': from_step.id, 'from_step_id': from_step.id,
@@ -214,6 +219,28 @@ class FpTabletMoveController(http.Controller):
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
# Partial-flow "light up" (2026-06-02 partial-order handling).
# A normal forward transfer that parks parts at the destination
# makes that stage actionable — flip pending -> ready so the
# receiving operator immediately sees a "Ready" card in their
# column with zero action by anyone. Never downgrade a step that
# is already past pending. Hold/scrap/rework/return route parts
# elsewhere and must NOT auto-ready a recipe step, so gate on
# transfer_type == 'step'.
if transfer_type == 'step' and to_step.state == 'pending':
to_step.state = 'ready'
# No auto-START — that begins the labour timer, which stays an
# explicit operator tap (keeps cost accurate; avoids the S16
# phantom-timer problem).
# Auto-finish the source when THIS forward move drained it to zero
# parked parts — one fewer tap. Best-effort: swallows finish-gate
# failures so the move always stands. Restricted to 'step' moves:
# a step drained by a HOLD still has unresolved held parts and
# must not auto-finish.
if transfer_type == 'step':
from_step._fp_try_autofinish_on_drain()
# Manager-bypass audit trail # Manager-bypass audit trail
ctx = request.env.context ctx = request.env.context
bypass_flags = [ bypass_flags = [
@@ -279,7 +306,7 @@ class FpTabletMoveController(http.Controller):
'batches': [ 'batches': [
{ {
'step_id': s.id, 'step_id': s.id,
'qty': (s.qty_done or 0) - (s.qty_scrapped or 0), 'qty': s.qty_at_step or 0,
'part_number': (s.job_id.product_id.default_code or ''), 'part_number': (s.job_id.product_id.default_code or ''),
'wo_number': s.job_id.name or '', 'wo_number': s.job_id.name or '',
} }
@@ -343,7 +370,7 @@ class FpTabletMoveController(http.Controller):
moves = [] moves = []
for batch in Step.search([('rack_id', '=', rack.id)]): for batch in Step.search([('rack_id', '=', rack.id)]):
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0) qty = batch.qty_at_step or 0
move = Move.create({ move = Move.create({
'job_id': batch.job_id.id, 'job_id': batch.job_id.id,
'from_step_id': batch.id, 'from_step_id': batch.id,
@@ -353,9 +380,19 @@ class FpTabletMoveController(http.Controller):
'rack_id': rack.id, 'rack_id': rack.id,
'to_tank_id': to_tank_id or False, 'to_tank_id': to_tank_id or False,
}) })
batch.qty_at_step_finish = qty batch.qty_at_step_finish = (batch.qty_at_step_finish or 0) + qty
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
moves.append(move.id) moves.append(move.id)
# Partial-flow "light up" — auto-finish the drained source
# batch (best-effort; see _fp_try_autofinish_on_drain).
if transfer_type == 'step':
batch._fp_try_autofinish_on_drain()
# Auto-ready the destination once parts have arrived (pending ->
# ready) so the receiving operator sees an actionable card. No
# auto-start (labour timer stays an explicit tap).
if transfer_type == 'step' and to_step.state == 'pending':
to_step.state = 'ready'
rack.racking_state = 'in_use' rack.racking_state = 'in_use'
return {'move_ids': moves, 'count': len(moves)} return {'move_ids': moves, 'count': len(moves)}

View File

@@ -10,7 +10,7 @@ docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
""" """
import json import json
import logging import logging
from datetime import date, datetime, timedelta from datetime import date, datetime
from odoo import _, http from odoo import _, http
from odoo.http import request from odoo.http import request
@@ -110,19 +110,28 @@ class PlantKanbanController(http.Controller):
jobs = Job.search(domain, limit=500) jobs = Job.search(domain, limit=500)
# Bucket by area_kind of the active step (or 'receiving' when no # Partial-order handling (2026-06-02): a job shows up as a card in
# active step yet — matches the contract_review / no_parts states # EVERY stage where it currently has parts (a "presence"), not just
# that live in Receiving column per spec §3 D5). # the single active-step column. Cards are keyed by a composite
# "{job_id}:{area}" so one job can appear in several columns. A job
# whose parts are all at one stage produces exactly one presence —
# byte-for-byte identical to the previous one-card-per-job board.
cards = {} cards = {}
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS} cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
for job in jobs: for job in jobs:
area = _resolve_card_area(job) active_area = (job.active_step_id.area_kind
cards_by_area.setdefault(area, []).append(job.id) if job.active_step_id else _resolve_card_area(job))
cards[str(job.id)] = _render_card(job, paired) for area, focus_step, qty_here in _job_presences(job):
key = '%s:%s' % (job.id, area)
cards[key] = _render_presence(
job, area, focus_step, qty_here,
area == active_area, paired,
)
cards_by_area.setdefault(area, []).append(key)
# Sort within each column by priority then due date # Sort within each column by priority then due date
for area in cards_by_area: for area in cards_by_area:
cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)])) cards_by_area[area].sort(key=lambda k: _sort_key(cards[k]))
columns = [ columns = [
{ {
@@ -251,21 +260,109 @@ def _resolve_card_area(job):
return 'receiving' return 'receiving'
def _render_card(job, paired): def _job_presences(job):
"""Build the full card payload for one fp.job.""" """Return the list of (area, focus_step, qty_here) presences for a job.
# Sudo the job recordset so cross-module field reads (sale.order,
# fp.part.catalog, fusion.plating.customer.spec) don't AccessError One entry per Shop Floor area where the job currently has parts parked
# for low-privilege roles like Technician. The output is denormalized OR an actionable (in_progress / paused / ready) step. This is what lets
# display data; the underlying record visibility is controlled by the a split job appear in several columns at once. A job whose parts are
# caller's fp.job ACL (Technician can read all jobs). all at one stage yields exactly ONE presence — byte-for-byte identical
to the previous one-card-per-job board.
"""
job = job.sudo()
Step = job.env['fp.job.step']
# Post-shop + no-parts states are single-column, state-driven (mirrors
# _resolve_card_area). No per-stage fan-out once the job has cleared
# the line or hasn't received parts yet.
if job.card_state == 'no_parts':
return [('receiving', job.active_step_id, 0)]
if job.state == 'awaiting_cert':
return [('inspection', Step, 0)]
if job.state == 'awaiting_ship':
return [('shipping', Step, 0)]
open_steps = job.step_ids.filtered(
lambda s: s.state not in ('done', 'skipped', 'cancelled')
)
by_area = {}
for s in open_steps:
by_area.setdefault(s.area_kind or 'plating', []).append(s)
presences = []
for area, steps in by_area.items():
qty_here = sum((s.qty_at_step or 0) for s in steps)
# A stage shows ONLY where parts physically are (qty_here > 0 —
# which includes the first-active step's qty_at_step seed) OR where
# a step is actively being worked (in_progress / paused — e.g.
# drained to zero but not yet finished). A merely `ready` / `pending`
# step with NO parts is a FUTURE stage and must NOT show — otherwise
# the job appears in every not-yet-started step at once (these
# recipes seed all downstream steps to `ready`, so 6 ready steps =
# 6 phantom cards; bug on WO-30061). Strict sequential progress
# falls out for free because the qty_at_step seed always sits on the
# lowest-sequence non-terminal step and advances as each completes.
being_worked = any(
s.state in ('in_progress', 'paused') for s in steps
)
if qty_here > 0 or being_worked:
presences.append((area, _pick_focus_step(steps), qty_here))
if not presences:
# Nothing parked and nothing actionable — fall back to the single
# resolved column so the job never vanishes from the board.
return [(_resolve_card_area(job), job.active_step_id, 0)]
return presences
def _pick_focus_step(steps):
"""The most-actionable step in an area: in_progress > paused > ready >
pending, lowest sequence within a state. Drives the presence card's
step label, operator pill, and tap target (focus_step_id)."""
ordered = sorted(steps, key=lambda s: s.sequence or 0)
for state in ('in_progress', 'paused', 'ready', 'pending'):
for s in ordered:
if s.state == state:
return s
return ordered[0] if ordered else None
def _secondary_card_state(step, paired):
"""Card state for a NON-primary presence (a stage other than the job's
active step). Derived purely from the focus step so the operator at
that stage gets an honest 'running' / 'ready' chip. The PRIMARY
presence keeps the full job-level card_state (holds, QC, bake, etc.)."""
if not step:
return 'ready'
mine = bool(
paired and step.work_centre_id
and step.work_centre_id.id == paired.id
)
if step.state == 'in_progress':
return 'running_mine' if mine else 'running'
if step.state == 'paused':
return 'running'
# ready / pending → queued at this stage
return 'ready_mine' if mine else 'ready'
def _render_presence(job, area, step, qty_here, is_primary, paired):
"""Build a card payload for one (job, stage) presence.
The PRIMARY presence (the job's active-step column) carries the full
job-level card_state so every existing job-level signal (hold, QC,
bake-due, sign-off, idle, post-shop) renders exactly as before.
SECONDARY presences derive a simpler state from their own focus step.
Sudo the job so cross-module reads (sale.order, fp.part.catalog,
customer.spec) don't AccessError for low-privilege roles (Rule 13m) —
the output is denormalized display data; fp.job ACL gates visibility.
"""
job = job.sudo() job = job.sudo()
step = job.active_step_id
try: try:
timeline = json.loads(job.mini_timeline_json or '[]') timeline = json.loads(job.mini_timeline_json or '[]')
except (TypeError, ValueError): except (TypeError, ValueError):
timeline = [] timeline = []
# Cross-module field probes (sudo'd via job.sudo() above)
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
so = job.sale_order_id so = job.sale_order_id
@@ -274,10 +371,11 @@ def _render_card(job, paired):
if so and 'x_fc_po_number' in so._fields: if so and 'x_fc_po_number' in so._fields:
po_number = so.x_fc_po_number or '' po_number = so.x_fc_po_number or ''
# Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable)
tags = _compute_tags(job, part, spec) tags = _compute_tags(job, part, spec)
# Step + tank labels card_state = (job.card_state if is_primary
else _secondary_card_state(step, paired))
step_name = step.name if step else _('') step_name = step.name if step else _('')
step_seq = step.sequence if step else 0 step_seq = step.sequence if step else 0
step_total = len(job.step_ids) step_total = len(job.step_ids)
@@ -285,23 +383,15 @@ def _render_card(job, paired):
if step and step.work_centre_id: if step and step.work_centre_id:
tank_label = step.work_centre_id.name or step.work_centre_id.code or '' tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
# State chip state_chip = _state_chip(card_state, step)
state_chip = _state_chip(job.card_state, step)
# Operator pill (only when step has an assigned user)
operator = None operator = None
if step and step.assigned_user_id: if step and step.assigned_user_id:
u = step.assigned_user_id u = step.assigned_user_id
operator = { operator = {'id': u.id, 'name': u.name, 'initials': _initials_for(u)}
'id': u.id,
'name': u.name,
'initials': _initials_for(u),
}
# Icon row
icons = _icons(job, step) icons = _icons(job, step)
# Due label
due_label = _due_label(job.date_deadline) if job.date_deadline else '' due_label = _due_label(job.date_deadline) if job.date_deadline else ''
is_overdue = ( is_overdue = (
bool(job.date_deadline) bool(job.date_deadline)
@@ -311,9 +401,17 @@ def _render_card(job, paired):
return { return {
'job_id': job.id, 'job_id': job.id,
# Composite identity — one job can have several presences.
'card_key': '%s:%s' % (job.id, area),
'area_kind': area,
'is_primary': is_primary,
# Partial-order fields: parts parked at THIS stage vs whole job.
'qty_here': int(qty_here or 0),
'job_qty': int(job.qty or 0),
'focus_step_id': step.id if step else False,
'wo_name': job.display_wo_name or job.name or '', 'wo_name': job.display_wo_name or job.name or '',
'is_mine': job.card_state in ('ready_mine', 'running_mine'), 'is_mine': card_state in ('ready_mine', 'running_mine'),
'card_state': job.card_state or '', 'card_state': card_state or '',
'due_date': (job.date_deadline.strftime('%Y-%m-%d') 'due_date': (job.date_deadline.strftime('%Y-%m-%d')
if job.date_deadline else None), if job.date_deadline else None),
'due_label': due_label, 'due_label': due_label,

View File

@@ -76,6 +76,11 @@ class FpWorkspaceController(http.Controller):
'kind': step.kind or 'other', 'kind': step.kind or 'other',
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''), 'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
'state': step.state, 'state': step.state,
# Partial-order handling — parts currently parked at this
# step. Drives the "Send to next" button visibility + the
# per-step "N here" hint; the Move dialog pre-fills from the
# same number via the preview endpoint.
'qty_at_step': int(getattr(step, 'qty_at_step', 0) or 0),
'assigned_user_id': step.assigned_user_id.id or False, 'assigned_user_id': step.assigned_user_id.id or False,
'assigned_user_name': step.assigned_user_id.name or '', 'assigned_user_name': step.assigned_user_id.name or '',
'work_centre_name': step.work_centre_id.name or '', 'work_centre_name': step.work_centre_id.name or '',

View File

@@ -60,11 +60,15 @@ export class FpPlantCard extends Component {
onCardClick() { onCardClick() {
const c = this.props.card; const c = this.props.card;
if (!c.job_id) return; if (!c.job_id) return;
// Open the workspace focused on THIS stage's step (partial-order
// handling) — tapping the Baking card lands on the Baking step,
// not the job's global active step. The workspace already accepts
// focus_step_id (see the FP-STEP scan path in plant_kanban.js).
this.action.doAction({ this.action.doAction({
type: "ir.actions.client", type: "ir.actions.client",
tag: "fp_job_workspace", tag: "fp_job_workspace",
target: "current", target: "current",
params: { job_id: c.job_id }, params: { job_id: c.job_id, focus_step_id: c.focus_step_id || false },
}); });
} }
} }

View File

@@ -30,11 +30,12 @@ import { FpTabletLock } from "./tablet_lock";
import { FpRackPartsDialog } from "./rack_parts_dialog"; import { FpRackPartsDialog } from "./rack_parts_dialog";
import { FpDamageDialog } from "./fp_damage_dialog"; import { FpDamageDialog } from "./fp_damage_dialog";
import { FpFinishBlockDialog } from "./fp_finish_block_dialog"; import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
import { FpMovePartsDialog } from "./move_parts_dialog";
export class FpJobWorkspace extends Component { export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace"; static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"]; static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog }; static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, FpMovePartsDialog };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
@@ -225,7 +226,21 @@ export class FpJobWorkspace extends Component {
if (step.override_excluded) return []; if (step.override_excluded) return [];
const actions = []; const actions = [];
// Partial-order handling — "Send to next →" advances parts parked
// at this step to the next stage. Only shown when parts are here
// AND a next stage exists. The destination name is on the button
// so there's nothing to guess; qty defaults to all parked here.
const advanceAction = () => {
const nxt = this.nextStepFor(step);
if (nxt && (step.qty_at_step || 0) > 0) {
return { key: "advance", label: "Send → " + nxt.name,
icon: "fa fa-arrow-right", cssClass: "btn btn-primary" };
}
return null;
};
if (step.state === "in_progress") { if (step.state === "in_progress") {
const adv = advanceAction();
if (adv) actions.push(adv);
actions.push({ key: "record_inputs", label: "Record Inputs", actions.push({ key: "record_inputs", label: "Record Inputs",
icon: "fa fa-pencil", cssClass: "btn btn-secondary" }); icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
actions.push({ key: "pause", label: "Pause", actions.push({ key: "pause", label: "Pause",
@@ -240,6 +255,8 @@ export class FpJobWorkspace extends Component {
if (step.state === "paused") { if (step.state === "paused") {
actions.push({ key: "resume", label: "Resume", actions.push({ key: "resume", label: "Resume",
icon: "fa fa-play", cssClass: "btn btn-primary" }); icon: "fa fa-play", cssClass: "btn btn-primary" });
const adv = advanceAction();
if (adv) actions.push(adv);
actions.push({ key: "record_inputs", label: "Record Inputs", actions.push({ key: "record_inputs", label: "Record Inputs",
icon: "fa fa-pencil", cssClass: "btn btn-secondary" }); icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
actions.push({ actions.push({
@@ -281,6 +298,7 @@ export class FpJobWorkspace extends Component {
case "mark_passed": return this.onMarkPassed(step); case "mark_passed": return this.onMarkPassed(step);
case "open_contract_review": return this.onOpenContractReview(step); case "open_contract_review": return this.onOpenContractReview(step);
case "start_with_rack": return this.onStartWithRack(step); case "start_with_rack": return this.onStartWithRack(step);
case "advance": return this.onAdvanceStep(step);
} }
} }
@@ -463,6 +481,44 @@ export class FpJobWorkspace extends Component {
}); });
} }
// ---- Partial-order advance (2026-06-02) -------------------------------
// "Send to next →" — moves parts parked at this step to the next stage.
// The destination auto-readies server-side (move_controller), so the
// receiving operator sees a Ready card immediately; the source
// auto-finishes when it drains to zero. Pure client-side next-step
// resolution off the loaded step list — no extra RPC.
nextStepFor(step) {
// The next stage parts flow into: lowest-sequence non-terminal step
// after this one. Returns null at the end of the line (parts finish
// in place there and close out at job mark-done).
const steps = (this.state.data && this.state.data.steps) || [];
const candidates = steps
.filter((s) => s.sequence > step.sequence
&& ["pending", "ready", "paused", "in_progress"].includes(s.state))
.sort((a, b) => a.sequence - b.sequence);
return candidates.length ? candidates[0] : null;
}
onAdvanceStep(step) {
const nxt = this.nextStepFor(step);
if (!nxt) {
this.notification.add(
"This is the last stage — parts finish here and close out at job completion.",
{ type: "warning" },
);
return;
}
// Open the slim Move dialog pre-set to advance to the next stage.
// Qty defaults to all parked here (qty_at_step) via the preview
// endpoint; the operator confirms or trims it with the steppers.
this.dialog.add(FpMovePartsDialog, {
fromStepId: step.id,
toStepId: nxt.id,
onCommit: async () => { await this.refresh(); },
});
}
// ---- Receiving handlers (Spec C1+C2 2026-05-24) ----------------------- // ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
// The receiver card at the top of the workspace lets the dock receiver // The receiver card at the top of the workspace lets the dock receiver
// count boxes, set per-line received quantities + condition, log damage // count boxes, set per-line received quantities + condition, log damage

View File

@@ -40,6 +40,11 @@ export class FpMovePartsDialog extends Component {
promptValues: {}, promptValues: {},
blockers: [], blockers: [],
committing: false, committing: false,
// Advanced fields (Transfer Type, To Location) stay collapsed
// by default — the everyday flow is "advance all to the next
// stage", which needs none of them. Keeps the dialog to a qty
// confirm + SEND for the 95% case.
showAdvanced: false,
}); });
onWillStart(async () => { onWillStart(async () => {
await this.loadPreview(); await this.loadPreview();
@@ -152,4 +157,20 @@ export class FpMovePartsDialog extends Component {
{ type: "warning" }); { type: "warning" });
} }
} }
// ---- Qty steppers (no keyboard) ---------------------------------------
// The operator taps / + or "All". Clamped to [1, qtyAvailable] so the
// count can never exceed what's parked here.
incQty() {
if (this.state.qty < this.state.qtyAvailable) this.state.qty += 1;
}
decQty() {
if (this.state.qty > 1) this.state.qty -= 1;
}
setQtyAll() {
this.state.qty = this.state.qtyAvailable;
}
toggleAdvanced() {
this.state.showAdvanced = !this.state.showAdvanced;
}
} }

View File

@@ -26,5 +26,5 @@ $_gate-text-hex: #b06600;
.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; } .o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; }
.o_fp_gate_body { flex: 1; } .o_fp_gate_body { flex: 1; }
.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; } .o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; }
.o_fp_gate_reason { color: var(--text-secondary, #666); font-size: 0.78rem; margin-top: 0.1rem; } .o_fp_gate_reason { color: var(--bs-secondary-color, #666); font-size: 0.78rem; margin-top: 0.1rem; }
.o_fp_gate_jump { flex-shrink: 0; } .o_fp_gate_jump { flex-shrink: 0; }

View File

@@ -17,5 +17,5 @@
.o_fp_hc_row label { .o_fp_hc_row label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
color: var(--text-secondary, #666); color: var(--bs-secondary-color, #666);
} }

View File

@@ -34,18 +34,18 @@ $_kc-hover-hex: #f5f5f7;
} }
.o_fp_kcard_h2 { .o_fp_kcard_h2 {
color: var(--text-secondary, #666); color: var(--bs-secondary-color, #666);
font-size: 0.75rem; font-size: 0.75rem;
margin-top: 0.15rem; margin-top: 0.15rem;
} }
.o_fp_kcard_qty { .o_fp_kcard_qty {
display: flex; justify-content: space-between; display: flex; justify-content: space-between;
font-size: 0.7rem; color: var(--text-secondary, #777); font-size: 0.7rem; color: var(--bs-secondary-color, #777);
margin-top: 0.3rem; margin-top: 0.3rem;
} }
.o_fp_kcard_due { color: var(--text-secondary, #999); } .o_fp_kcard_due { color: var(--bs-secondary-color, #999); }
.o_fp_kcard_bar { .o_fp_kcard_bar {
height: 4px; background: rgba(0,0,0,0.08); height: 4px; background: rgba(0,0,0,0.08);
@@ -74,7 +74,7 @@ $_kc-hover-hex: #f5f5f7;
} }
.o_fp_kcard_wc { .o_fp_kcard_wc {
color: var(--text-secondary, #999); color: var(--bs-secondary-color, #999);
font-size: 0.7rem; font-size: 0.7rem;
} }

View File

@@ -38,7 +38,7 @@ $_pin-dot-fill-hex: #1d1d1f;
} }
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; } .o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; } .o_fp_pin_subtitle { font-size: 0.85rem; color: var(--bs-secondary-color, #666); text-align: center; }
.o_fp_pin_dots { .o_fp_pin_dots {
display: flex; display: flex;
@@ -83,8 +83,8 @@ $_pin-dot-fill-hex: #1d1d1f;
&:disabled { opacity: 0.5; cursor: wait; } &:disabled { opacity: 0.5; cursor: wait; }
} }
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); } .o_fp_pin_key_clear { font-size: 0.95rem; color: var(--bs-secondary-color, #666); }
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); } .o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--bs-secondary-color, #666); }
@keyframes o_fp_pin_shake_kf { @keyframes o_fp_pin_shake_kf {
0%, 100% { transform: translateX(0); } 0%, 100% { transform: translateX(0); }

View File

@@ -91,6 +91,21 @@
.card-sub-em { color: $plant-text; font-weight: 600; } .card-sub-em { color: $plant-text; font-weight: 600; }
.card-meta { font-size: 11px; color: $plant-muted; } .card-meta { font-size: 11px; color: $plant-muted; }
.card-step { font-size: 14px; font-weight: 600; color: $plant-text; margin-top: 2px; } .card-step { font-size: 14px; font-weight: 600; color: $plant-text; margin-top: 2px; }
// Partial-order handling — "20 of 50 here" per-stage count. The big
// number pops so an operator scanning their column instantly sees how
// many of a job's parts are at their station. Uses existing tokens so
// dark mode is handled at compile time by _plant_tokens.scss.
.card-qty-here {
font-size: 12px;
color: $plant-muted;
margin-top: 1px;
.qty-here-num {
font-size: 16px;
font-weight: 800;
color: $plant-mine-border;
letter-spacing: -0.01em;
}
}
.card-chips { display: flex; flex-wrap: wrap; gap: 4px; } .card-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip { .chip {

View File

@@ -21,7 +21,7 @@ $_sig-canvas-border-hex: #d8dadd;
.o_fp_sig_ctx { .o_fp_sig_ctx {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary, #666); color: var(--bs-secondary-color, #666);
} }
.o_fp_sig_canvas { .o_fp_sig_canvas {
@@ -36,6 +36,6 @@ $_sig-canvas-border-hex: #d8dadd;
.o_fp_sig_hint { .o_fp_sig_hint {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary, #999); color: var(--bs-secondary-color, #999);
text-align: center; text-align: center;
} }

View File

@@ -29,7 +29,7 @@ $_ws-text-hex: #1d1d1f;
.o_fp_ws_loading { .o_fp_ws_loading {
margin: auto; margin: auto;
text-align: center; text-align: center;
color: var(--text-secondary, #666); color: var(--bs-secondary-color, #666);
> div { margin-top: 0.6rem; } > div { margin-top: 0.6rem; }
} }
@@ -87,8 +87,8 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_ws_wo { font-weight: 700; font-size: 1.3rem; letter-spacing: 0.01em; } .o_fp_ws_wo { font-weight: 700; font-size: 1.3rem; letter-spacing: 0.01em; }
.o_fp_ws_dot { color: var(--text-secondary, #999); } .o_fp_ws_dot { color: var(--bs-secondary-color, #999); }
.o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #555); font-size: 0.95rem; } .o_fp_ws_cust, .o_fp_ws_part { color: var(--bs-secondary-color, #555); font-size: 0.95rem; }
.o_fp_ws_pill { .o_fp_ws_pill {
background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%); background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%);
@@ -97,7 +97,7 @@ $_ws-text-hex: #1d1d1f;
border-radius: 6px; border-radius: 6px;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #555); color: var(--bs-secondary-color, #555);
box-shadow: 0 1px 2px rgba(0,0,0,0.04); box-shadow: 0 1px 2px rgba(0,0,0,0.04);
} }
@@ -152,7 +152,7 @@ $_ws-text-hex: #1d1d1f;
.o_fp_ws_bar_label { .o_fp_ws_bar_label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #888); color: var(--bs-secondary-color, #888);
margin-top: 0.35rem; margin-top: 0.35rem;
text-align: center; text-align: center;
} }
@@ -237,7 +237,7 @@ $_ws-text-hex: #1d1d1f;
.o_fp_ws_empty { .o_fp_ws_empty {
text-align: center; text-align: center;
padding: 2rem 1rem; padding: 2rem 1rem;
color: var(--text-secondary, #999); color: var(--bs-secondary-color, #999);
> div { margin-top: 0.5rem; } > div { margin-top: 0.5rem; }
} }
@@ -270,9 +270,9 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; } .o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
.o_fp_ws_step_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; } .o_fp_ws_step_num { color: var(--bs-secondary-color, #999); font-size: 0.78rem; min-width: 50px; }
.o_fp_ws_step_name { font-weight: 600; } .o_fp_ws_step_name { font-weight: 600; }
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; } .o_fp_ws_step_meta { color: var(--bs-secondary-color, #999); font-size: 0.78rem; margin-left: auto; }
.o_fp_ws_step_badge { .o_fp_ws_step_badge {
background: #0071e3; background: #0071e3;
@@ -295,12 +295,12 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; } .o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; } .o_fp_ws_step_instr { font-size: 0.78rem; color: var(--bs-secondary-color, #555); font-style: italic; }
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; } .o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.o_fp_ws_step_excluded { .o_fp_ws_step_excluded {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--text-secondary, #888); color: var(--bs-secondary-color, #888);
font-style: italic; font-style: italic;
} }
@@ -331,7 +331,7 @@ $_ws-text-hex: #1d1d1f;
font-size: 0.7rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--text-secondary, #777); color: var(--bs-secondary-color, #777);
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
} }
} }
@@ -362,14 +362,14 @@ $_ws-text-hex: #1d1d1f;
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
font-size: 0.72rem; font-size: 0.72rem;
color: var(--text-secondary, #777); color: var(--bs-secondary-color, #777);
} }
.o_fp_ws_note .author { font-weight: 600; } .o_fp_ws_note .author { font-weight: 600; }
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; } .o_fp_ws_note .body { color: var(--bs-secondary-color, #555); margin-top: 0.15rem; }
.o_fp_ws_empty_small { .o_fp_ws_empty_small {
color: var(--text-secondary, #999); color: var(--bs-secondary-color, #999);
font-size: 0.75rem; font-size: 0.75rem;
font-style: italic; font-style: italic;
} }
@@ -417,7 +417,7 @@ $_ws-text-hex: #1d1d1f;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary, #777); color: var(--bs-secondary-color, #777);
} }
.o_fp_ws_ship_fields { .o_fp_ws_ship_fields {
@@ -488,8 +488,8 @@ $_ws-text-hex: #1d1d1f;
.o_fp_ws_rcv_status { .o_fp_ws_rcv_status {
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
border-radius: 4px; border-radius: 4px;
background: #fef3c7; background-color: color-mix(in srgb, #f59e0b 18%, var(--bs-body-bg));
color: #78350f; color: var(--bs-body-color);
font-weight: 600; font-weight: 600;
font-size: 0.85rem; font-size: 0.85rem;
text-transform: uppercase; text-transform: uppercase;
@@ -507,7 +507,7 @@ $_ws-text-hex: #1d1d1f;
label { label {
font-weight: 600; font-weight: 600;
color: var(--text-secondary, #555); color: var(--bs-secondary-color, #555);
font-size: 0.9rem; font-size: 0.9rem;
} }
} }
@@ -529,7 +529,7 @@ $_ws-text-hex: #1d1d1f;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-secondary, #666); color: var(--bs-secondary-color, #666);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -598,7 +598,7 @@ $_ws-text-hex: #1d1d1f;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--text-secondary, #666); color: var(--bs-secondary-color, #666);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -644,7 +644,7 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_ws_rcv_damage_photos { .o_fp_ws_rcv_damage_photos {
color: var(--text-secondary, #666); color: var(--bs-secondary-color, #666);
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -680,7 +680,7 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_dmg_field { display: flex; flex-direction: column; gap: 0.4rem; } .o_fp_dmg_field { display: flex; flex-direction: column; gap: 0.4rem; }
.o_fp_dmg_label { font-weight: 600; color: var(--text-secondary, #555); } .o_fp_dmg_label { font-weight: 600; color: var(--bs-secondary-color, #555); }
.o_fp_dmg_req { color: #dc2626; } .o_fp_dmg_req { color: #dc2626; }
.o_fp_dmg_pills { .o_fp_dmg_pills {
@@ -784,8 +784,8 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_ws_step_timer_over { .o_fp_ws_step_timer_over {
background: #fee2e2; background-color: color-mix(in srgb, #ef4444 16%, var(--bs-body-bg));
color: #7f1d1d; color: var(--bs-body-color);
animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite; animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite;
} }
@@ -806,17 +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: #b45309; background-color: rgba(245, 158, 11, 0.16);
background: #fef3c7;
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(--text-secondary, #333); font-weight: 500;
} }
.o_fp_finish_block_list { .o_fp_finish_block_list {
@@ -831,9 +839,9 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_finish_block_action_note { .o_fp_finish_block_action_note {
color: var(--text-secondary, #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: #f3f4f6; background: rgba(128, 128, 128, 0.12);
border-radius: 4px; border-radius: 4px;
} }

View File

@@ -173,3 +173,89 @@ $fp-md-page: var(--fp-page-bg, #{$_fp_md_page_hex});
} }
} }
} }
// ============================ Partial-order handling — easy-advance layout
// "Send Parts Forward" dialog: destination banner + big-tap qty stepper
// (no keyboard) + collapsed advanced fields. Reuses the $fp-md-* tokens so
// dark mode is handled at compile time.
.o_fp_move_dialog {
.o_fp_move_route {
display: flex;
align-items: center;
justify-content: center;
gap: .5rem;
flex-wrap: wrap;
padding: .6rem .75rem;
background: $fp-md-page;
border: 1px solid $fp-md-border;
border-radius: 6px;
font-weight: 600;
.route-from { color: $fp-md-muted; }
.route-arrow { color: $fp-md-accent; font-weight: 800; }
.route-to { color: $fp-md-accent; }
}
.o_fp_move_qty {
display: flex;
flex-direction: column;
align-items: center;
gap: .35rem;
label { font-weight: 600; margin: 0; }
}
.o_fp_qty_stepper {
display: flex;
align-items: center;
gap: .5rem;
.qty-btn {
width: 3rem;
height: 3rem;
font-size: 1.5rem;
font-weight: 700;
line-height: 1;
border: 1px solid $fp-md-border;
border-radius: 8px;
background: $fp-md-card;
color: $fp-md-accent;
&:disabled { opacity: .4; }
}
.qty-value {
min-width: 3.5rem;
text-align: center;
font-size: 1.75rem;
font-weight: 800;
}
.qty-all {
margin-left: .5rem;
padding: .5rem .9rem;
border: 1px solid $fp-md-border;
border-radius: 8px;
background: $fp-md-card;
font-weight: 600;
}
}
.o_fp_qty_hint {
color: $fp-md-muted;
font-size: .85rem;
}
.o_fp_move_advanced_toggle {
text-align: center;
.btn-link { color: $fp-md-muted; text-decoration: none; }
}
.o_fp_move_advanced {
display: flex;
flex-direction: column;
gap: .5rem;
padding: .6rem .75rem;
border: 1px dashed $fp-md-border;
border-radius: 6px;
}
}

View File

@@ -72,6 +72,8 @@
} }
} }
.toolbar-btn { .toolbar-btn {
display: inline-flex;
align-items: center;
padding: 8px 14px; padding: 8px 14px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@@ -81,8 +83,10 @@
cursor: pointer; cursor: pointer;
color: $plant-text; color: $plant-text;
font-family: inherit; font-family: inherit;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(0,0,0,0.05); box-shadow: 0 1px 2px rgba(0,0,0,0.05);
transition: transform 0.1s ease, box-shadow 0.1s ease; transition: transform 0.1s ease, box-shadow 0.1s ease;
i { font-size: 15px; line-height: 1; }
&:hover { &:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.08); box-shadow: 0 2px 4px rgba(0,0,0,0.08);
@@ -93,6 +97,24 @@
color: #5e4400; color: #5e4400;
font-weight: 700; font-weight: 700;
} }
// Scan pair — matched look. "Scan QR" (camera, the primary way to
// scan a printed job sticker) is accent-filled so it stands out;
// "Enter Code" (manual / hardware scanner-gun) is the accent-tinted
// secondary. Matched FA icons (fa-qrcode / fa-keyboard-o), no emoji.
&.o_fp_qr_btn {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
border-color: #1d4ed8;
color: #fff;
font-weight: 600;
i { color: #fff; }
&:hover { box-shadow: 0 3px 8px rgba(29, 78, 216, 0.32); }
}
&.scan-alt {
background: linear-gradient(135deg, $plant-mine-bg 0%, $plant-card-bg 100%);
border-color: $plant-mine-border;
font-weight: 600;
i { color: #1d4ed8; }
}
} }
// 8 tiles — Work Orders, At My Station, Bakes Due, On Hold, // 8 tiles — Work Orders, At My Station, Bakes Due, On Hold,

View File

@@ -47,6 +47,14 @@
<!-- Step name --> <!-- Step name -->
<div class="card-step" t-esc="props.card.step_name"/> <div class="card-step" t-esc="props.card.step_name"/>
<!-- Parts at THIS stage (partial-order handling). "20 of 50"
so a per-stage presence is never mistaken for a whole job.
Hidden when nothing is parked here (post-shop / empty). -->
<div t-if="props.card.qty_here" class="card-qty-here">
<span class="qty-here-num" t-esc="props.card.qty_here"/>
<span class="qty-here-of"> of <t t-esc="props.card.job_qty"/> here</span>
</div>
<!-- Tank + state chip --> <!-- Tank + state chip -->
<div class="card-chips"> <div class="card-chips">
<span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/> <span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>

View File

@@ -2,75 +2,48 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.FpMovePartsDialog"> <t t-name="fusion_plating_shopfloor.FpMovePartsDialog">
<Dialog title.translate="Move Parts" size="'lg'"> <Dialog title.translate="Send Parts Forward" size="'md'">
<div class="o_fp_move_dialog" t-if="!state.loading"> <div class="o_fp_move_dialog" t-if="!state.loading">
<div class="o_fp_move_field"> <!-- Destination banner — operator sees exactly where parts go,
<label>Part Count</label> nothing to guess. -->
<input type="number" t-model.number="state.qty" <div class="o_fp_move_route">
t-att-min="1" t-att-max="state.qtyAvailable"/> <span class="route-from" t-esc="state.fromStep.name"/>
<span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span> <span class="route-arrow"></span>
<span class="route-to" t-esc="state.toStep.name"/>
</div> </div>
<div class="o_fp_move_field"> <!-- Qty stepper — no keyboard. Defaults to all parked here. -->
<label>From Node</label> <div class="o_fp_move_qty">
<span t-esc="state.fromStep.name"/> <label>How many to send?</label>
<span/> <div class="o_fp_qty_stepper">
</div> <button class="qty-btn" t-on-click="decQty"
t-att-disabled="state.qty &lt;= 1"></button>
<div class="o_fp_move_field" t-if="state.fromStep.tank_name"> <span class="qty-value" t-esc="state.qty"/>
<label>From Station</label> <button class="qty-btn" t-on-click="incQty"
<span t-esc="state.fromStep.tank_name"/> t-att-disabled="state.qty &gt;= state.qtyAvailable">+</button>
<span/> <button class="qty-all" t-on-click="setQtyAll">
</div> All (<t t-esc="state.qtyAvailable"/>)
</button>
<div class="o_fp_move_field"> </div>
<label>Transfer Type</label> <span class="o_fp_qty_hint"><t t-esc="state.qtyAvailable"/> parked here</span>
<select t-model="state.transferType">
<option value="step">Step</option>
<option value="hold">Hold</option>
<option value="scrap">Scrap</option>
<option value="rework">Rework</option>
<option value="split">Split</option>
<option value="return">Return</option>
</select>
<span/>
</div>
<div class="o_fp_move_field">
<label>To Node</label>
<span t-esc="state.toStep.name"/>
<span/>
</div> </div>
<!-- To Station (tank) — only when the recipe offers a choice -->
<div class="o_fp_move_field" <div class="o_fp_move_field"
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1"> t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
<label>To Station</label> <label>To Station</label>
<select t-model.number="state.toTankId"> <select t-model.number="state.toTankId">
<t t-foreach="state.toStep.tank_options" <t t-foreach="state.toStep.tank_options" t-as="tk" t-key="tk.id">
t-as="tk" t-key="tk.id">
<option t-att-value="tk.id"><t t-esc="tk.name"/></option> <option t-att-value="tk.id"><t t-esc="tk.name"/></option>
</t> </t>
</select> </select>
<span/>
</div> </div>
<div class="o_fp_move_field"> <!-- Compliance prompts — only when the recipe author required
<label>To Location</label> them. Pickers/checkboxes, minimal free text. -->
<select t-model="state.toLocation"> <div class="o_fp_compliance_prompts" t-if="state.transitionPrompts.length">
<option value="global">Global</option> <h5>Required before sending</h5>
<option value="quarantine">Quarantine</option>
<option value="staging_a">Staging A</option>
<option value="staging_b">Staging B</option>
<option value="shipping_dock">Shipping Dock</option>
<option value="scrap_bin">Scrap Bin</option>
</select>
<span/>
</div>
<div class="o_fp_compliance_prompts"
t-if="state.transitionPrompts.length">
<h5>Compliance Prompts</h5>
<t t-foreach="state.transitionPrompts" t-as="p" t-key="p.id"> <t t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
<div class="o_fp_move_field"> <div class="o_fp_move_field">
<label> <label>
@@ -94,13 +67,12 @@
</t> </t>
</select> </select>
<span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span> <span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
<span t-else=""/>
</div> </div>
</t> </t>
</div> </div>
<!-- Blockers — inline resolve where possible -->
<div class="o_fp_blockers" t-if="state.blockers.length"> <div class="o_fp_blockers" t-if="state.blockers.length">
<h5>Blockers</h5>
<t t-foreach="state.blockers" t-as="b" t-key="b_index"> <t t-foreach="state.blockers" t-as="b" t-key="b_index">
<div class="o_fp_blocker_row" <div class="o_fp_blocker_row"
t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'"> t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
@@ -114,6 +86,39 @@
</div> </div>
</t> </t>
</div> </div>
<!-- More options (advanced) — hold / scrap / rework / location.
Collapsed by default so the everyday "advance all" flow is
a qty confirm + SEND. -->
<div class="o_fp_move_advanced_toggle">
<button class="btn btn-link btn-sm" t-on-click="toggleAdvanced">
<t t-if="state.showAdvanced">▾ Hide options</t>
<t t-else="">▸ More options (hold / scrap / location)</t>
</button>
</div>
<div t-if="state.showAdvanced" class="o_fp_move_advanced">
<div class="o_fp_move_field">
<label>Transfer Type</label>
<select t-model="state.transferType">
<option value="step">Send to next step</option>
<option value="hold">Hold</option>
<option value="scrap">Scrap</option>
<option value="rework">Rework</option>
<option value="return">Return</option>
</select>
</div>
<div class="o_fp_move_field">
<label>To Location</label>
<select t-model="state.toLocation">
<option value="global">Global</option>
<option value="quarantine">Quarantine</option>
<option value="staging_a">Staging A</option>
<option value="staging_b">Staging B</option>
<option value="shipping_dock">Shipping Dock</option>
<option value="scrap_bin">Scrap Bin</option>
</select>
</div>
</div>
</div> </div>
<div t-if="state.loading">Loading…</div> <div t-if="state.loading">Loading…</div>
@@ -126,7 +131,7 @@
t-att-disabled="!canCommit" t-att-disabled="!canCommit"
t-att-title="blockerTooltip" t-att-title="blockerTooltip"
t-on-click="onCommit"> t-on-click="onCommit">
MOVE (<t t-esc="state.qty"/>) SEND (<t t-esc="state.qty"/>)
</button> </button>
</t> </t>
</Dialog> </Dialog>

View File

@@ -23,13 +23,18 @@
<button t-att-class="modeClass('manager')" <button t-att-class="modeClass('manager')"
t-on-click="() => this.setMode('manager')">Manager</button> t-on-click="() => this.setMode('manager')">Manager</button>
</div> </div>
<!-- Text/wedge scan drawer toggle. Camera path <!-- "Scan QR" = the QrScanner camera path (the
is the QrScanner inline below — it primary way to scan a printed job sticker).
opens its own modal + decoder. --> The component renders its own fa-qrcode
<button class="toolbar-btn" icon, so the label must be plain text — an
t-att-class="state.showScan ? 'toolbar-btn active' : 'toolbar-btn'" emoji here would double up the icon.
t-on-click="toggleScan">⌨️ Scan Code</button> "Enter Code" = the manual / hardware-scanner-
<QrScanner cssClass="'toolbar-btn'" label="'📷 Camera'"/> gun text drawer (a wedge gun types the code;
no camera). -->
<QrScanner cssClass="'toolbar-btn'" label="'Scan QR'"/>
<button class="toolbar-btn scan-alt"
t-att-class="state.showScan ? 'active' : ''"
t-on-click="toggleScan"><i class="fa fa-keyboard-o me-1"/>Enter Code</button>
<button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button> <button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button>
</div> </div>
</div> </div>

View File

@@ -2494,6 +2494,13 @@ class AuthorizerPortal(CustomerPortal):
if not assessment_type: if not assessment_type:
return {'success': False, 'error': 'Assessment type is required'} return {'success': False, 'error': 'Assessment type is required'}
# Funding source drives the downstream sale-order workflow; coerce
# anything unexpected to private pay (mirrors /book-assessment).
_funding_keys = dict(Assessment._fields['x_fc_funding_source'].selection)
funding_source = post.get('funding_source') or 'direct_private'
if funding_source not in _funding_keys:
funding_source = 'direct_private'
# Build assessment values # Build assessment values
vals = { vals = {
'assessment_type': assessment_type, 'assessment_type': assessment_type,
@@ -2507,6 +2514,7 @@ class AuthorizerPortal(CustomerPortal):
'client_address_postal': post.get('client_address_postal', '').strip(), 'client_address_postal': post.get('client_address_postal', '').strip(),
'client_phone': post.get('client_phone', '').strip(), 'client_phone': post.get('client_phone', '').strip(),
'client_email': post.get('client_email', '').strip(), 'client_email': post.get('client_email', '').strip(),
'x_fc_funding_source': funding_source,
'notes': post.get('notes', '').strip(), 'notes': post.get('notes', '').strip(),
} }

View File

@@ -73,6 +73,7 @@ class FusionAccessibilityAssessment(models.Model):
('march_of_dimes', 'March of Dimes'), ('march_of_dimes', 'March of Dimes'),
('odsp', 'ODSP'), ('odsp', 'ODSP'),
('wsib', 'WSIB'), ('wsib', 'WSIB'),
('hardship', 'Hardship Funding'),
('insurance', 'Private Insurance'), ('insurance', 'Private Insurance'),
('direct_private', 'Private Pay (Direct)'), ('direct_private', 'Private Pay (Direct)'),
('other', 'Other'), ('other', 'Other'),
@@ -772,6 +773,7 @@ class FusionAccessibilityAssessment(models.Model):
'march_of_dimes': 'march_of_dimes', 'march_of_dimes': 'march_of_dimes',
'odsp': 'odsp', 'odsp': 'odsp',
'wsib': 'wsib', 'wsib': 'wsib',
'hardship': 'hardship',
'insurance': 'insurance', 'insurance': 'insurance',
'direct_private': 'direct_private', 'direct_private': 'direct_private',
'other': 'other', 'other': 'other',

View File

@@ -373,6 +373,21 @@
<input type="email" name="client_email" class="form-control" placeholder="email@example.com"/> <input type="email" name="client_email" class="form-control" placeholder="email@example.com"/>
</div> </div>
</div> </div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Funding Source <span class="text-danger">*</span></label>
<select name="funding_source" class="form-select" required="required">
<option value="direct_private" selected="selected">Private Pay (Direct)</option>
<option value="march_of_dimes">March of Dimes</option>
<option value="odsp">ODSP</option>
<option value="wsib">WSIB</option>
<option value="hardship">Hardship Funding</option>
<option value="insurance">Private Insurance</option>
<option value="other">Other</option>
</select>
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>