Compare commits

..

6 Commits

Author SHA1 Message Date
gsinghpal
21cfd55419 feat(fusion_portal): Phase 3 - assessment visit workspace (accessibility path)
Adds the portal workspace: /my/visit/new starts a visit; /my/visit/<id> shows the
add-as-you-go workspace (add buttons -> existing forms carrying ?visit_id, a
deferred client+funding form, and a Complete button). Accessibility forms launched
from a visit save as a DRAFT linked to it (JS carries visit_id into the form; the
controller captures it and skips the per-assessment SO) - the VISIT completion then
creates the grouped per-funding sale orders.

NOT YET: express/ADP form visit-linking, email consolidation, polished tablet UI.
Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:37:08 -04:00
gsinghpal
89467432a7 feat(fusion_portal): Phase 2b - ADP multi-device grouping + combination guard
The visit groups its ADP assessments by funding type onto ONE ADP order (first
device creates the SO via the existing express completion; the rest attach),
enforcing the combination rule: at most one seated-mobility device (manual WC /
power WC / scooter) + optionally one walker, no duplicates. Also fixes a Phase 1b
bug - it called action_complete() (needs signatures, returns an action dict) for
ADP; now uses action_complete_express() which returns the SO.

Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:31:38 -04:00
gsinghpal
e0ddd9ef40 feat(fusion_portal): Phase 2a - mobility scooter ADP type + power-mobility home-access rule
Adds 'scooter' as a 4th ADP equipment type with a lean Express-form section
(scooter type + max range) and the power-mobility home-accessibility hard rule
(scooter + powerchair): "is the home usable inside and outside, no lifting?" - if No,
prompts adding an accessibility item (ramp / porch lift). Captures
x_fc_power_home_accessible + notes; the section toggles via the existing
equipment-select JS; controller parses the new fields.

NOT YET: ADP multi-device + combination rules (the bigger restructure).
Untested locally (Enterprise dep) - to be verified on a westin-v19 clone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:22:47 -04:00
gsinghpal
b17bd615bf feat(fusion_portal): Phase 1b - Assessment Visit model + accessibility funding grouping
Adds fusion.assessment.visit: the hub that bundles a home visit's assessments and,
on completion, groups its ACCESSIBILITY assessments by funding workflow
(x_fc_sale_type) and creates ONE draft sale order per workflow, reusing the existing
MOD/ODSP/etc. pipelines + the chatter-note pattern. ADP assessments keep one-SO-each
for now (ADP multi-device grouping is Phase 2).

- New model + sequence (VISIT/YYYY/NNNN) + ACL + backend list/form/menu.
- visit_id added to fusion.assessment, fusion.accessibility.assessment, sale.order.
- action_complete_visit() group-and-routes; MOD $15k cap + income-threshold flag
  surfaced (informational, no auto-enforcement).

NOT YET: completion-email consolidation; ADP multi-device (Phase 2); portal
add-as-you-go workspace (Phase 3). Untested locally (Enterprise knowledge dep) -
to be verified on a westin-v19 clone before prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:00:53 -04:00
gsinghpal
e36aaab306 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>
2026-06-02 01:38:42 -04:00
gsinghpal
37efc5b858 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>
2026-06-02 01:33:24 -04:00
35 changed files with 184 additions and 2055 deletions

View File

@@ -1,194 +0,0 @@
# 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

@@ -1,506 +0,0 @@
# 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

@@ -1,298 +0,0 @@
# 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

@@ -630,27 +630,8 @@ De-Racking → Final inspection → Shipping`
Columns are first-class — they always render in this exact order, never Columns are first-class — they always render in this exact order, never
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind` reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
(stored) in `_compute_area_kind` (`fusion_plating_jobs/models/fp_job_step.py`): (stored) from `work_centre.area_kind` with a fallback to a step-kind
`work_centre.area_kind` → else `recipe_node.kind_id.area_kind` (the dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
`fp.step.kind` taxonomy is authoritative; the legacy `_STEP_KIND_TO_AREA`
dict is gone) → else catch-all `'plating'`.
**Gating/"Ready for X" marker steps fall FORWARD (fixed 2026-06-02).** The
`fp.step.kind` named *Gating* has `code='gating'` **and `area_kind='receiving'`**.
A gating step is a non-physical "ready for the next stage" marker, so
mapping it to Receiving made a *mid-recipe* gate snap the job's card back
to the first column (Racking → "Ready for processing" jumped to Receiving,
so the job looked like it vanished). `_compute_area_kind` therefore detects
a gating step via the **stable `kind_id.code == 'gating'`** (never the
display name) and resolves its column to the **next non-gating step's** raw
area (so "Ready for processing" before plating shows in the **Plating**
column); if nothing real follows, it falls back to the last real stage.
Helpers: `_fp_is_gating_step`, `_fp_raw_area_kind` (own work_centre/kind
only — no look-ahead, avoids recursion), `_fp_resolve_area_kind`. **NB:**
`area_kind` is a STORED compute, so after changing this logic you must
force-recompute existing rows (`env['fp.job.step'].search([])._compute_area_kind()`
+ `flush_recordset(['area_kind'])` + commit) — a `-u`/restart alone leaves
old values stale.
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip, **Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide, Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
@@ -1851,41 +1832,3 @@ 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. **Two non-obvious traps in `_fp_try_autofinish_on_drain` (both fixed 2026-06-02):** (1) it must guard on a real **OUTGOING** move (`move_ids` to a different step, `qty_moved > 0`), NOT `_fp_has_real_incoming()` — the FIRST/seeded stage (e.g. Racking) is fed by the `qty_at_step` seed, has no incoming move, and so never auto-finished when all its parts were sent forward. (2) It is **best-effort and gated**: `button_finish` still runs the required-step-input / sign-off / contract-review gates, so a step with an unrecorded required input (e.g. Racking's "Count the Parts") will NOT auto-finish on drain — it stays `in_progress` with `qty_at_step=0` ("running, 0 here → finish me") until the operator records the input and finishes. That's correct (can't complete a step missing compliance data); don't try to force auto-finish past the gates.
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream. **Second, distinct trap (fixed 2026-06-02): the Move dialog's `_blockers_for_move` predecessor check must only flag unfinished steps STRICTLY BETWEEN `from_step` and `to_step` (`from_step.sequence < s.sequence < to_step.sequence`), NOT all steps before `to_step`.** The original `s.sequence < to_step.sequence` filter counted the `from_step` itself (which is in-progress *by definition* when you advance partial parts out of it) as an "unfinished predecessor" of the destination — so EVERY partial advance to a not-yet-started next step showed a hard "Predecessor not done: \<from_step\>" blocker and greyed out SEND (hit on WO-30061). The between-only rule allows the immediate-next advance, still blocks skip-ahead moves over incomplete intermediate stages, and leaves backward (rework) moves unblocked (empty range).
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
**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

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

@@ -124,18 +124,8 @@ class FpTabletMoveController(http.Controller):
hasattr(to_step, '_fp_should_block_predecessors') hasattr(to_step, '_fp_should_block_predecessors')
and to_step._fp_should_block_predecessors() and to_step._fp_should_block_predecessors()
): ):
# Partial-flow (2026-06-02): only an unfinished step STRICTLY
# BETWEEN from_step and to_step blocks the move (you'd be skipping
# an incomplete intermediate stage). The from_step itself is
# in-progress BY DEFINITION when advancing partial parts out of
# it — counting it (or any earlier step) as an "unfinished
# predecessor" blocked every partial advance to a not-yet-started
# next step. Steps before from_step are irrelevant: the parts
# being moved are physically at from_step, ready for the next
# stage. Backward moves (rework: from > to) yield an empty range
# and are never predecessor-blocked.
unfinished = to_step.job_id.step_ids.filtered( unfinished = to_step.job_id.step_ids.filtered(
lambda s: from_step.sequence < s.sequence < to_step.sequence lambda s: s.sequence < to_step.sequence
and s.state not in ('done', 'skipped', 'cancelled') and s.state not in ('done', 'skipped', 'cancelled')
) )
if unfinished: if unfinished:
@@ -157,12 +147,7 @@ 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)
# Available-to-move = parts currently parked here (qty_at_step — qty = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
# 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,
@@ -201,7 +186,7 @@ class FpTabletMoveController(http.Controller):
if hard: if hard:
raise UserError(hard[0]['message']) raise UserError(hard[0]['message'])
qty_avail = from_step.qty_at_step or 0 qty_avail = (from_step.qty_done or 0) - (from_step.qty_scrapped 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,
@@ -229,28 +214,6 @@ 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 = [
@@ -316,7 +279,7 @@ class FpTabletMoveController(http.Controller):
'batches': [ 'batches': [
{ {
'step_id': s.id, 'step_id': s.id,
'qty': s.qty_at_step or 0, 'qty': (s.qty_done or 0) - (s.qty_scrapped 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 '',
} }
@@ -380,7 +343,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_at_step or 0 qty = (batch.qty_done or 0) - (batch.qty_scrapped 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,
@@ -390,19 +353,9 @@ 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 = (batch.qty_at_step_finish or 0) + qty batch.qty_at_step_finish = 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 from datetime import date, datetime, timedelta
from odoo import _, http from odoo import _, http
from odoo.http import request from odoo.http import request
@@ -110,28 +110,19 @@ class PlantKanbanController(http.Controller):
jobs = Job.search(domain, limit=500) jobs = Job.search(domain, limit=500)
# Partial-order handling (2026-06-02): a job shows up as a card in # Bucket by area_kind of the active step (or 'receiving' when no
# EVERY stage where it currently has parts (a "presence"), not just # active step yet — matches the contract_review / no_parts states
# the single active-step column. Cards are keyed by a composite # that live in Receiving column per spec §3 D5).
# "{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:
active_area = (job.active_step_id.area_kind area = _resolve_card_area(job)
if job.active_step_id else _resolve_card_area(job)) cards_by_area.setdefault(area, []).append(job.id)
for area, focus_step, qty_here in _job_presences(job): cards[str(job.id)] = _render_card(job, paired)
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 k: _sort_key(cards[k])) cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)]))
columns = [ columns = [
{ {
@@ -260,109 +251,21 @@ def _resolve_card_area(job):
return 'receiving' return 'receiving'
def _job_presences(job): def _render_card(job, paired):
"""Return the list of (area, focus_step, qty_here) presences for a job. """Build the full card payload for one fp.job."""
# Sudo the job recordset so cross-module field reads (sale.order,
One entry per Shop Floor area where the job currently has parts parked # fp.part.catalog, fusion.plating.customer.spec) don't AccessError
OR an actionable (in_progress / paused / ready) step. This is what lets # for low-privilege roles like Technician. The output is denormalized
a split job appear in several columns at once. A job whose parts are # display data; the underlying record visibility is controlled by the
all at one stage yields exactly ONE presence — byte-for-byte identical # caller's fp.job ACL (Technician can read all jobs).
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
@@ -371,11 +274,10 @@ def _render_presence(job, area, step, qty_here, is_primary, 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)
card_state = (job.card_state if is_primary # Step + tank labels
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)
@@ -383,15 +285,23 @@ def _render_presence(job, area, step, qty_here, is_primary, 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(card_state, step) # State chip
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 = {'id': u.id, 'name': u.name, 'initials': _initials_for(u)} operator = {
'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)
@@ -401,17 +311,9 @@ def _render_presence(job, area, step, qty_here, is_primary, 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': card_state in ('ready_mine', 'running_mine'), 'is_mine': job.card_state in ('ready_mine', 'running_mine'),
'card_state': card_state or '', 'card_state': job.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,11 +76,6 @@ 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,15 +60,11 @@ 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, focus_step_id: c.focus_step_id || false }, params: { job_id: c.job_id },
}); });
} }
} }

View File

@@ -30,12 +30,11 @@ 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, FpMovePartsDialog }; static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
@@ -226,21 +225,7 @@ 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",
@@ -255,8 +240,6 @@ 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({
@@ -298,7 +281,6 @@ 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);
} }
} }
@@ -481,44 +463,6 @@ 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,11 +40,6 @@ 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();
@@ -157,20 +152,4 @@ 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(--bs-secondary-color, #666); font-size: 0.78rem; margin-top: 0.1rem; } .o_fp_gate_reason { color: var(--text-secondary, #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(--bs-secondary-color, #666); color: var(--text-secondary, #666);
} }

View File

@@ -34,18 +34,18 @@ $_kc-hover-hex: #f5f5f7;
} }
.o_fp_kcard_h2 { .o_fp_kcard_h2 {
color: var(--bs-secondary-color, #666); color: var(--text-secondary, #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(--bs-secondary-color, #777); font-size: 0.7rem; color: var(--text-secondary, #777);
margin-top: 0.3rem; margin-top: 0.3rem;
} }
.o_fp_kcard_due { color: var(--bs-secondary-color, #999); } .o_fp_kcard_due { color: var(--text-secondary, #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(--bs-secondary-color, #999); color: var(--text-secondary, #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(--bs-secondary-color, #666); text-align: center; } .o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #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(--bs-secondary-color, #666); } .o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--bs-secondary-color, #666); } .o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #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,21 +91,6 @@
.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(--bs-secondary-color, #666); color: var(--text-secondary, #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(--bs-secondary-color, #999); color: var(--text-secondary, #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(--bs-secondary-color, #666); color: var(--text-secondary, #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(--bs-secondary-color, #999); } .o_fp_ws_dot { color: var(--text-secondary, #999); }
.o_fp_ws_cust, .o_fp_ws_part { color: var(--bs-secondary-color, #555); font-size: 0.95rem; } .o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #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(--bs-secondary-color, #555); color: var(--text-secondary, #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(--bs-secondary-color, #888); color: var(--text-secondary, #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(--bs-secondary-color, #999); color: var(--text-secondary, #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(--bs-secondary-color, #999); font-size: 0.78rem; min-width: 50px; } .o_fp_ws_step_num { color: var(--text-secondary, #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(--bs-secondary-color, #999); font-size: 0.78rem; margin-left: auto; } .o_fp_ws_step_meta { color: var(--text-secondary, #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(--bs-secondary-color, #555); font-style: italic; } .o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #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(--bs-secondary-color, #888); color: var(--text-secondary, #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(--bs-secondary-color, #777); color: var(--text-secondary, #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(--bs-secondary-color, #777); color: var(--text-secondary, #777);
} }
.o_fp_ws_note .author { font-weight: 600; } .o_fp_ws_note .author { font-weight: 600; }
.o_fp_ws_note .body { color: var(--bs-secondary-color, #555); margin-top: 0.15rem; } .o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
.o_fp_ws_empty_small { .o_fp_ws_empty_small {
color: var(--bs-secondary-color, #999); color: var(--text-secondary, #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(--bs-secondary-color, #777); color: var(--text-secondary, #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-color: color-mix(in srgb, #f59e0b 18%, var(--bs-body-bg)); background: #fef3c7;
color: var(--bs-body-color); color: #78350f;
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(--bs-secondary-color, #555); color: var(--text-secondary, #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(--bs-secondary-color, #666); color: var(--text-secondary, #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(--bs-secondary-color, #666); color: var(--text-secondary, #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(--bs-secondary-color, #666); color: var(--text-secondary, #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(--bs-secondary-color, #555); } .o_fp_dmg_label { font-weight: 600; color: var(--text-secondary, #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-color: color-mix(in srgb, #ef4444 16%, var(--bs-body-bg)); background: #fee2e2;
color: var(--bs-body-color); color: #7f1d1d;
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,25 +806,17 @@ $_ws-text-hex: #1d1d1f;
gap: 1rem; gap: 1rem;
} }
// NOTE: Odoo's backend CSS does NOT define --bs-body-color /
// --bs-secondary-color / --bs-*-bg as custom properties (verified: 0
// definitions in the compiled bundle — they're SCSS literals + two
// bundles + [data-bs-theme]). So var(--bs-body-color, #hex) ALWAYS
// resolves to the dark #hex fallback, in light AND dark mode. The fix
// for dialog text is to INHERIT the modal's theme-correct colour (the
// dialog title and the "Count the Parts" list items do exactly this and
// are readable in both modes). Tinted boxes use translucent rgba() so
// they work over whatever the live theme background is.
.o_fp_finish_block_step { .o_fp_finish_block_step {
font-size: 1.1rem; font-size: 1.1rem;
background-color: rgba(245, 158, 11, 0.16); color: #b45309;
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 {
font-weight: 500; color: var(--text-secondary, #333);
} }
.o_fp_finish_block_list { .o_fp_finish_block_list {
@@ -839,9 +831,9 @@ $_ws-text-hex: #1d1d1f;
} }
.o_fp_finish_block_action_note { .o_fp_finish_block_action_note {
// Inherit text colour; translucent neutral box works in both themes. color: var(--text-secondary, #555);
font-style: italic; font-style: italic;
padding: 0.6rem 0.8rem; padding: 0.6rem 0.8rem;
background: rgba(128, 128, 128, 0.12); background: #f3f4f6;
border-radius: 4px; border-radius: 4px;
} }

View File

@@ -173,89 +173,3 @@ $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,8 +72,6 @@
} }
} }
.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;
@@ -83,10 +81,8 @@
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);
@@ -97,24 +93,6 @@
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,14 +47,6 @@
<!-- 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,48 +2,75 @@
<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="Send Parts Forward" size="'md'"> <Dialog title.translate="Move Parts" size="'lg'">
<div class="o_fp_move_dialog" t-if="!state.loading"> <div class="o_fp_move_dialog" t-if="!state.loading">
<!-- Destination banner — operator sees exactly where parts go, <div class="o_fp_move_field">
nothing to guess. --> <label>Part Count</label>
<div class="o_fp_move_route"> <input type="number" t-model.number="state.qty"
<span class="route-from" t-esc="state.fromStep.name"/> t-att-min="1" t-att-max="state.qtyAvailable"/>
<span class="route-arrow"></span> <span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span>
<span class="route-to" t-esc="state.toStep.name"/>
</div> </div>
<!-- Qty stepper — no keyboard. Defaults to all parked here. --> <div class="o_fp_move_field">
<div class="o_fp_move_qty"> <label>From Node</label>
<label>How many to send?</label> <span t-esc="state.fromStep.name"/>
<div class="o_fp_qty_stepper"> <span/>
<button class="qty-btn" t-on-click="decQty" </div>
t-att-disabled="state.qty &lt;= 1"></button>
<span class="qty-value" t-esc="state.qty"/> <div class="o_fp_move_field" t-if="state.fromStep.tank_name">
<button class="qty-btn" t-on-click="incQty" <label>From Station</label>
t-att-disabled="state.qty &gt;= state.qtyAvailable">+</button> <span t-esc="state.fromStep.tank_name"/>
<button class="qty-all" t-on-click="setQtyAll"> <span/>
All (<t t-esc="state.qtyAvailable"/>) </div>
</button>
</div> <div class="o_fp_move_field">
<span class="o_fp_qty_hint"><t t-esc="state.qtyAvailable"/> parked here</span> <label>Transfer Type</label>
<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-as="tk" t-key="tk.id"> <t t-foreach="state.toStep.tank_options"
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>
<!-- Compliance prompts — only when the recipe author required <div class="o_fp_move_field">
them. Pickers/checkboxes, minimal free text. --> <label>To Location</label>
<div class="o_fp_compliance_prompts" t-if="state.transitionPrompts.length"> <select t-model="state.toLocation">
<h5>Required before sending</h5> <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>
<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>
@@ -67,12 +94,13 @@
</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'">
@@ -86,39 +114,6 @@
</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>
@@ -131,7 +126,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">
SEND (<t t-esc="state.qty"/>) MOVE (<t t-esc="state.qty"/>)
</button> </button>
</t> </t>
</Dialog> </Dialog>

View File

@@ -23,18 +23,13 @@
<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>
<!-- "Scan QR" = the QrScanner camera path (the <!-- Text/wedge scan drawer toggle. Camera path
primary way to scan a printed job sticker). is the QrScanner inline below — it
The component renders its own fa-qrcode opens its own modal + decoder. -->
icon, so the label must be plain text — an <button class="toolbar-btn"
emoji here would double up the icon. t-att-class="state.showScan ? 'toolbar-btn active' : 'toolbar-btn'"
"Enter Code" = the manual / hardware-scanner- t-on-click="toggleScan">⌨️ Scan Code</button>
gun text drawer (a wedge gun types the code; <QrScanner cssClass="'toolbar-btn'" label="'📷 Camera'"/>
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

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'Fusion Authorizer & Sales Portal', 'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.10.1', 'version': '19.0.2.8.0',
'category': 'Sales/Portal', 'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """ 'description': """

View File

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

View File

@@ -1486,18 +1486,20 @@ class FusionAssessment(models.Model):
}) })
def _send_completion_notifications(self): def _send_completion_notifications(self):
"""Notify the CLIENT that the assessment is complete. """Send email notifications when assessment is completed"""
The authorizer, sales rep and office are already emailed (with the full
assessment report) by ``_send_assessment_completed_email`` inside
``_create_draft_sale_order``. This method used to ALSO send the
authorizer a second, template-only email — that duplicate is removed;
here we only notify the client.
"""
self.ensure_one() self.ensure_one()
# Send to client (authorizer/rep/office already emailed by # Send to authorizer
# _send_assessment_completed_email in _create_draft_sale_order) if self.authorizer_id and self.authorizer_id.email:
try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=True)
_logger.info(f"Sent assessment completion email to authorizer {self.authorizer_id.email}")
except Exception as e:
_logger.error(f"Failed to send authorizer notification: {e}")
# Send to client
if self.client_email: if self.client_email:
try: try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False) template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False)

View File

@@ -184,16 +184,6 @@ class FusionAssessmentVisit(models.Model):
subtype_xmlid='mail.mt_note', subtype_xmlid='mail.mt_note',
) )
assessment.write({'state': 'completed'}) assessment.write({'state': 'completed'})
# One completion notification per SO (not per assessment) — mirrors the
# standalone accessibility completion's office email.
if accessibility_assessments:
try:
accessibility_assessments[0]._send_completion_email(sale_order)
except Exception as e:
_logger.warning(
"Visit %s: completion email failed for %s: %s",
self.name, sale_order.name, e,
)
_logger.info( _logger.info(
"Visit %s created %s sale order %s grouping %d accessibility assessment(s)", "Visit %s created %s sale order %s grouping %d accessibility assessment(s)",
self.name, sale_type, sale_order.name, len(accessibility_assessments), self.name, sale_type, sale_order.name, len(accessibility_assessments),
@@ -216,15 +206,6 @@ class FusionAssessmentVisit(models.Model):
if len(walkers) > 1: if len(walkers) > 1:
raise UserError(_('An ADP order can include only one walker / rollator.')) raise UserError(_('An ADP order can include only one walker / rollator.'))
def _assessment_sale_type(self, adp_assessment):
"""Funding workflow key for an ADP equipment assessment, mirroring
fusion.assessment._create_draft_sale_order: ADP+ODSP when the client
type is an ODSP stream, plain ADP otherwise. ADP devices that share a
key are grouped onto one sale order."""
if adp_assessment.client_type in ('ods', 'acs', 'owp'):
return 'adp_odsp'
return 'adp'
def action_complete_visit(self): def action_complete_visit(self):
"""Group the visit's accessibility assessments by funding workflow and """Group the visit's accessibility assessments by funding workflow and
create one draft SO per workflow. ADP equipment assessments keep their create one draft SO per workflow. ADP equipment assessments keep their

View File

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

View File

@@ -85,18 +85,6 @@
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="assessment_id" t-att-value="assessment.id if assessment else ''"/> <input type="hidden" name="assessment_id" t-att-value="assessment.id if assessment else ''"/>
<input type="hidden" name="current_page" value="1"/> <input type="hidden" name="current_page" value="1"/>
<input type="hidden" name="visit_id" id="express_visit_id" t-att-value="visit_id or ''"/>
<!-- Part of an assessment visit: completing returns to the visit, which groups
this device with the rest into one funding-routed ADP order. -->
<div t-if="visit_id" class="alert alert-info d-flex align-items-start gap-2 mb-3">
<i class="fa fa-clipboard mt-1"/>
<div>
<strong>Part of an assessment visit.</strong>
Completing this device returns you to the visit — it is grouped with the
visit's other ADP devices into a single sale order when you complete the visit.
</div>
</div>
<!-- Equipment Selection Section --> <!-- Equipment Selection Section -->
<div class="card shadow-sm"> <div class="card shadow-sm">
@@ -1258,10 +1246,9 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body text-center"> <div class="card-body text-center">
<button type="submit" name="action" value="submit" class="btn btn-success btn-lg px-5"> <button type="submit" name="action" value="submit" class="btn btn-success btn-lg px-5">
<t t-if="visit_id"><i class="fa fa-clipboard me-2"/>Save to Visit</t> <i class="fa fa-check me-2"/>Submit Assessment
<t t-else=""><i class="fa fa-check me-2"/>Submit Assessment</t>
</button> </button>
<a t-att-href="('/my/visit/%s' % visit_id) if visit_id else '/my/assessments'" class="btn btn-outline-secondary btn-lg px-4 ms-3"> <a href="/my/assessments" class="btn btn-outline-secondary btn-lg px-4 ms-3">
Cancel Cancel
</a> </a>
</div> </div>

View File

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