Compare commits
16 Commits
feat/asses
...
feat/fusio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f7e90a3d | ||
|
|
903ceb10d0 | ||
|
|
4f48bab6e9 | ||
|
|
b616375679 | ||
|
|
5c4a26b65f | ||
|
|
b59ad6b21e | ||
|
|
8a1a09b150 | ||
|
|
a092c385ea | ||
|
|
ca44461b6f | ||
|
|
249adf8145 | ||
|
|
cc568b0ec8 | ||
|
|
17d21bffb5 | ||
|
|
6c3830fd4c | ||
|
|
12d383a8c2 | ||
|
|
139e917e09 | ||
|
|
de3e0df5fc |
194
docs/plans/fusion_maintenance_brainstorm.md
Normal file
194
docs/plans/fusion_maintenance_brainstorm.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# fusion_maintenance — Brainstorm & Handoff Brief
|
||||
|
||||
> Status: **research/brainstorm only — no code, no final decisions.** Written from a
|
||||
> Claude Code *web* session that could **not** reach the private network (no Tailscale,
|
||||
> no docker daemon, Supabase KB unreachable). Resume from a **Tailscale-connected env**
|
||||
> (dev box or a host that can reach Westin production) and do the live inspection in
|
||||
> Step 0 **before** committing to the design.
|
||||
|
||||
## Goal (user's words, paraphrased)
|
||||
Automated maintenance follow-ups for mobility/accessibility equipment we've sold, to turn
|
||||
service into **recurring revenue**. Reminder emails → client books maintenance → booking
|
||||
happens in **real time** and **lands in our calendar**. Leverage Odoo Enterprise's
|
||||
appointment system. Decide whether this lives in `fusion_repairs` or a new module — the
|
||||
result must be **seamless and production-ready**.
|
||||
|
||||
## Decisions locked with the user (this session)
|
||||
- **Same DB**: `fusion_claims` + `fusion_repairs` run on one database → new module may depend on both.
|
||||
- **Enterprise `appointment` is available** → build real-time booking ON it (`appointment.type` /
|
||||
`appointment.slot` / `calendar.event`), do **not** hand-roll a calendar.
|
||||
- **Public self-serve booking** → reminder email carries a token link to a no-login slot picker
|
||||
(extend the existing `/repairs/maintenance/book/<token>` pattern). Elderly clients shouldn't log in.
|
||||
- **Target box for grounding = Westin production** (where `fusion_claims` runs day-to-day).
|
||||
|
||||
## Key findings from repo exploration
|
||||
|
||||
### `fusion_repairs` (v19.0.2.2.6) ALREADY has a maintenance engine — reuse it, don't fork
|
||||
- `fusion.repair.maintenance.contract`: interval, due/last-service dates, state machine.
|
||||
Auto-spawned on SO confirm when `product.template.x_fc_maintenance_interval_months > 0`.
|
||||
- Daily reminder cron `cron_maintenance_due_reminders` → 30/7/1-day bands → branded email
|
||||
`email_template_maintenance_due_reminder` with tokenized link `/repairs/maintenance/book/<token>`.
|
||||
- Booking controller: `controllers/portal_maintenance_booking.py` — **single date-confirm form,
|
||||
NO slot availability, NO conflict check, NO calendar event.** ← this is the real gap.
|
||||
- Contract **roll-forward** on technician-task completion (`next_due_date += interval`).
|
||||
- `fusion.repair.service.plan.subscription`: pre-paid visit plans (recurring-revenue primitive).
|
||||
- Deps: `repair, maintenance, sale_management, stock, purchase, website, portal, fusion_tasks,
|
||||
fusion_poynt, fusion_authorizer_portal`. ~8.3k LOC, 25+ models.
|
||||
|
||||
### `fusion_claims` (v19.0.9.2.0) is the ideal trigger source
|
||||
- Claim container = `sale.order` (`x_fc_sale_type`: adp, odsp, wsib, insurance, march_of_dimes, …).
|
||||
- **Equipment unit** = `sale.order.line.x_fc_serial_number` + `product_id`.
|
||||
- **Equipment category** = `fusion.adp.device.code.device_type` (wheelchair, walker, hospital bed,
|
||||
stair lift, porch lift, custom ramp, …) — matches the user's "sale groups".
|
||||
- **Schedule anchors**: `x_fc_adp_delivery_date`, `x_fc_service_start_date`; gate on `x_fc_adp_approved`.
|
||||
- Customer = `sale.order.partner_id`; prescriber = `x_fc_authorizer_id`.
|
||||
- Already depends on `calendar, fusion_tasks, ai, fusion_ringcentral`.
|
||||
|
||||
## Proposed architecture (PENDING live verification)
|
||||
**New module `fusion_maintenance`** depending on `fusion_repairs`, `fusion_claims`, `appointment`.
|
||||
Reuses the existing contract/reminder/roll-forward engine; adds the 3 genuinely-missing pieces:
|
||||
|
||||
1. **`fusion.maintenance.policy`** (ops-configurable, no code per category):
|
||||
`device_type` → `interval_months`, reminder bands, `service_product_id` (priced visit),
|
||||
`appointment_type_id`, required technician skill. Turns "stair lift = 6 mo, $X" into data.
|
||||
2. **Claims bridge** (daily cron): scan `fusion_claims` `sale.order.line` for delivered+approved
|
||||
devices whose `device_type` matches an active policy → ensure a maintenance contract exists,
|
||||
anchored at `delivery_date + interval`. Idempotent (key on serial / sale-line). Extend the
|
||||
reused contract with `x_fc_source_claim_line_id`, `x_fc_device_type`, `x_fc_policy_id` so the
|
||||
repairs path and claims path both feed **one** contract model.
|
||||
3. **Real-time booking on `appointment`**: token link → slot picker backed by `appointment.type`
|
||||
(partner pre-resolved from token, no login). Slot pick → real `calendar.event` → hook spawns
|
||||
`repair.order` + technician task, assigns by skill/zone, advances reminder band, rolls contract
|
||||
forward.
|
||||
|
||||
**Recurring revenue**: each policy carries `service_product_id` → booked visit drafts a priced
|
||||
SO/invoice; optional pre-paid annual plan via existing `service.plan.subscription`; optional
|
||||
door payment via existing `fusion_poynt`.
|
||||
|
||||
## STEP 0 — run on Westin production FIRST (grounding before any decision)
|
||||
> Replace `APP`/`DB` with the real Westin container + database. CLAUDE.md rule #1: never code
|
||||
> from memory — read the real Enterprise `appointment` source before building the booking layer.
|
||||
|
||||
```bash
|
||||
# RESOLVED 2026-06-02 — Westin Odoo prod migrated OFF Digital Ocean onto the on-prem Proxmox
|
||||
# cluster. Old DO IPs (152.42.146.204 / 178.128.229.92) are DEAD (:22 timeout). Live box:
|
||||
# host `odoo-westin` = 192.168.1.40 via the `supabase-prod` Tailscale jump (Windows OpenSSH
|
||||
# ProxyCommand → run `ssh odoo-westin ...` from PowerShell). App container `odoo-dev-app`
|
||||
# (odoo:19, Enterprise); DB container `odoo-dev-db`; DB `westin-v19`; user `odoo` (local-socket
|
||||
# trust inside odoo-dev-db). Enterprise addons → /mnt/enterprise-addons, custom → /mnt/extra-addons.
|
||||
# SQL: ssh odoo-westin 'docker exec odoo-dev-db psql -U odoo -d westin-v19 -c "..."'
|
||||
# FS read: ssh odoo-westin 'docker exec odoo-dev-app sed -n 1,160p /mnt/enterprise-addons/...'
|
||||
APP=odoo-dev-app ; DB=westin-v19 ; DBC=odoo-dev-db
|
||||
|
||||
# 1) Install matrix — confirm same-DB + Enterprise appointment present + versions
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT name,state,latest_version FROM ir_module_module \
|
||||
WHERE name IN ('fusion_claims','fusion_repairs','fusion_maintenance','calendar','maintenance','repair') \
|
||||
OR name LIKE 'appointment%' ORDER BY name;"
|
||||
|
||||
# 2) Real device_type distribution (drives per-category policies)
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT device_type, count(*) FROM fusion_adp_device_code GROUP BY device_type ORDER BY 2 DESC;"
|
||||
|
||||
# 3) Locate the Enterprise appointment source (read, don't guess the API)
|
||||
docker exec "$APP" bash -lc 'ls -d /mnt/enterprise-addons/appointment 2>/dev/null || \
|
||||
find / -maxdepth 6 -type d -name appointment 2>/dev/null | grep -i addons | head'
|
||||
|
||||
# 4) Appointment model surface to build booking on (adjust path from #3)
|
||||
docker exec "$APP" cat <appointment_path>/models/appointment_type.py | head -160
|
||||
docker exec "$APP" ls <appointment_path>/controllers/ # find the public booking controller
|
||||
|
||||
# 5) How fusion_repairs maintenance contracts already look in live data
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT state, count(*) FROM fusion_repair_maintenance_contract GROUP BY state;"
|
||||
```
|
||||
|
||||
## STEP 0 — RESULTS (ran 2026-06-02 against Westin prod `westin-v19`)
|
||||
> Grounding facts only — **no design decisions made**. These correct several assumptions above.
|
||||
|
||||
**Connection (resolved):** host `odoo-westin` (192.168.1.40) via the `supabase-prod` Tailscale jump.
|
||||
App container `odoo-dev-app` (odoo:19, Enterprise), DB container `odoo-dev-db`, DB `westin-v19`,
|
||||
user `odoo`. Old Digital Ocean boxes are DEAD — Westin migrated on-prem.
|
||||
|
||||
**1) Install matrix** — `appointment` **19.0.1.3 installed** (+ `appointment_account_payment`,
|
||||
`_crm`, `_hr`, `_microsoft_calendar`, `_sms`). All deps present: `calendar`, `maintenance`, `repair`,
|
||||
`sale_management`, `portal`, `website`, `resource`, `phone_validation`, `web_gantt`. `fusion_claims`
|
||||
**19.0.9.2.0 installed**. `fusion_repairs` and `fusion_maintenance` are **absent entirely** (no
|
||||
records). → a module depending on `appointment` installs cleanly; "reuse the fusion_repairs engine"
|
||||
means *deploy fusion_repairs to Westin first* (heavy) **or** own a lean contract model here. Note
|
||||
Odoo's native `maintenance` (CMMS) is installed — an under-considered third reuse option.
|
||||
|
||||
**2) device_type** — 119 distinct values, but `fusion.adp.device.code` is the ADP billing-code
|
||||
**CATALOG** (`_order='device_type, device_code'`), so counts are catalog codes per type, **NOT units
|
||||
installed**. Top entries are seating COMPONENTS (Seat Cushion 564, Back Support 375, Headrest 193).
|
||||
The maintainable **equipment classes** ≈ wheelchairs (manual + power tilt), power bases, power
|
||||
scooters, wheeled walkers / walking frames, paediatric standing frames, specialty strollers (~6-8
|
||||
clean categories). → `device_type` can't be a 1:1 policy key (119 values, mostly parts); needs a
|
||||
grouping/whitelist. **Real install base sized on `sale.order.line`** (`x_fc_adp_device_type` [stored compute from
|
||||
product's `x_fc_adp_device_code_id.device_type`], `x_fc_serial_number`, `x_fc_adp_approved`; delivery
|
||||
dates `x_fc_adp_delivery_date` / `x_fc_service_start_date`) — **see the Install-base sizing block below.**
|
||||
|
||||
**3) + 4) Enterprise appointment source** — `/mnt/enterprise-addons/appointment`. The no-login token
|
||||
slot-picker is **mostly NATIVE — don't hand-roll it**: public booking (`auth="public"`), invite
|
||||
tokens (`appointment.invite`, `/appointment/<id>?…invite_token`), live availability
|
||||
(`/appointment/<id>/update_available_slots`, jsonrpc/public), slot submit → real `calendar.event`
|
||||
(`/appointment/<id>/submit`), auto/manual staff+resource assignment, capacity, booked/cancelled mail
|
||||
templates. Model `appointment.type`; controller `controllers/appointment.py`. → the module mainly
|
||||
needs to: seed an `appointment.type` per category, drop a partner-bound invite link into the reminder
|
||||
email, and hook `calendar.event` create → spawn the service task + advance the contract.
|
||||
`appointment_account_payment` is installed → native pay-to-book is on the table for the revenue mechanic.
|
||||
|
||||
**5) Maintenance-contract state** — `relation "fusion_repair_maintenance_contract" does not exist`
|
||||
→ confirms the fusion_repairs maintenance engine is **not** on Westin.
|
||||
|
||||
**Headline correction:** Westin's ADP data has **zero** stair lifts / porch lifts / ramps / hospital
|
||||
beds — those belong to the fusion_repairs / EN-Tech (mobility) domain. Westin's recurring-revenue
|
||||
play is **wheelchairs / power bases / scooters / walkers / seating**. Open questions updated below.
|
||||
|
||||
**Install-base sizing (ran 2026-06-02 — the REAL units, complementing #2's catalog counts).** Big tell:
|
||||
serial numbers are captured **~only on actual equipment** (every part/option/mod device_type shows 0
|
||||
serials), so `x_fc_serial_number` is already a de-facto "trackable unit" marker — convenient, because the
|
||||
bridge's idempotency key is the serial.
|
||||
|
||||
- **Addressable base ≈ 138 serial-tracked units across ~136 customers** (all funders). By equipment
|
||||
family (serial-tracked / of which delivered): **Walkers & walking frames 68 (55)**, **Wheelchairs 45
|
||||
(40)**, **Power bases 7 (6)**, **Scooters 4 (3)**, plus **14 units with no ADP device_type** (likely
|
||||
private-pay) and 1 misc.
|
||||
- **Funder split** (serial-tracked): adp 109, direct_private 13, adp_odsp 10, march_of_dimes 7;
|
||||
wsib / insurance / standalone-odsp / rental / regular = **0 serials**. → an ADP-only gate
|
||||
(`x_fc_adp_approved`) captures ~110 and **misses ~28** real units. The bridge should likely key on
|
||||
**serial (funder-agnostic)**, not approval.
|
||||
- **Two data gaps the design must absorb:** (a) the 14 serial units with no ADP device_type can't be
|
||||
classified by a device_type→policy map → need a product-level or manual category override; (b) non-ADP
|
||||
units have no `x_fc_adp_delivery_date` → the contract anchor (`delivery_date + interval`) needs a
|
||||
fallback (invoice/order date).
|
||||
- Deliveries span **2022-10 → 2026-05** (active program) — history to anchor intervals + a live pipeline.
|
||||
- Top serial-tracked device_types: Adult Wheeled Walker Type 3 (47), Adult Manual Dynamic Tilt Type 5
|
||||
Wheelchair (23), Adult Lightweight Performance Type 3 (11), Adult Lightweight Standard Type 1 (10),
|
||||
Adult Wheeled Walker Type 2 (9), Adult Power Base Type 3 (5), Power Scooter (3). (1 line ≈ 1 unit;
|
||||
equipment device_types are 1 base line each.)
|
||||
|
||||
## Open questions to resolve with the user (in the connected session)
|
||||
- **MVP cut**: which categories first? Sizing surfaces a real tension: **by volume** it's walkers (68) +
|
||||
wheelchairs (45) ≈ 82% of the base, but rollators/walkers are mechanically low-service; **by
|
||||
service-revenue-per-unit** the targets are the powered units (power bases 7 + scooters 4 + power
|
||||
wheelchairs) — high maintenance value but only ~11–15 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`).
|
||||
@@ -0,0 +1,506 @@
|
||||
# fusion_maintenance Foundation — Implementation Plan (Plan 1 of 5)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Confirming a sale of a maintainable product auto-creates a *priced* maintenance contract, and the due-reminder email shows the maintenance cost.
|
||||
|
||||
**Architecture:** Extend `fusion_repairs`. A maintenance **policy** (enabled / interval / flat fee) lives on `fusion.repair.product.category`, with a per-product fee/interval override on `product.template`. We fix the dead `_spawn_maintenance_contracts()` (anchor on delivery date, capture serial + fee + provenance, dedup) and call it from the **existing** `action_confirm()` override. The branded reminder email gains a fee line.
|
||||
|
||||
**Tech Stack:** Odoo 19 **Community**, Python, `TransactionCase`. Local dev: `docker odoo-modsdev-app`, DB `fusion-dev`.
|
||||
|
||||
**Spec:** [`2026-06-02-fusion-maintenance-design.md`](../specs/2026-06-02-fusion-maintenance-design.md). This is **Plan 1 of 5**; see the Roadmap at the bottom for Plans 2–5 (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 2–5 (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.
|
||||
298
docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md
Normal file
298
docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# fusion_maintenance — Design Spec
|
||||
|
||||
> Automated preventive‑maintenance follow‑ups + self‑serve real‑time 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 2026‑06‑02). 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 **1–6 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** (real‑time, self‑serve, 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 auto‑rescheduled** → 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 per‑visit revenue. Configured per equipment **category** with per‑product 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 | **Technician‑aware picker on `fusion_tasks`** (NOT Enterprise `appointment`) | Clients see only slots a qualified tech is genuinely free for (route/skill‑aware); booking creates the technician task directly — one scheduling world, no appointment↔task bridge. Bonus: **no Enterprise dependency → Community‑testable 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/1‑day** bands, per‑band dedup, queued
|
||||
branded email `email_template_maintenance_due_reminder` with the tokenized link.
|
||||
- Public booking controller `/repairs/maintenance/book/<token>` — `auth='public'`, token‑validated,
|
||||
already‑booked guard, thanks page.
|
||||
- `create_repair_from_booking()` — spawns a `repair.order` (`x_fc_intake_source='client_portal'`),
|
||||
links `x_fc_maintenance_contract_id`, dedups.
|
||||
- **Roll‑forward** 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.**
|
||||
- Pre‑paid **service‑plan 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.
|
||||
- Visit‑report 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) — **route‑aware** (tech start address + geocoding + travel). Tech skills on `res.users.x_fc_repair_skills`.
|
||||
|
||||
### 3.2 The 4 gaps this spec closes
|
||||
1. **Contract auto‑creation 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 tech‑task creation, no structured maintenance log, no office‑follow‑up crons**
|
||||
(`ir.config_parameter` toggles exist; no cron/Python).
|
||||
|
||||
### 3.3 Install‑base sizing (Westin live, 2026‑06‑02)
|
||||
- Serial numbers are captured **~only on real equipment** (parts have 0 serials) → `x_fc_serial_number`
|
||||
is a de‑facto "trackable unit" marker and the natural **idempotency key**.
|
||||
- ADP‑side base ≈ **138 serial‑tracked units / ~136 customers** (walkers 68, wheelchairs 45, power
|
||||
bases 7, scooters 4, +14 no‑device‑type). Funders: adp 109, direct_private 13, adp_odsp 10,
|
||||
march_of_dimes 7. Deliveries 2022‑10 → 2026‑05.
|
||||
- **Lifts (sized 2026‑06‑02; name‑based, 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 lift‑chair). So the serial‑as‑unit‑key
|
||||
approach that works for ADP wheelchairs **does NOT work for lifts** — lifts must be keyed by
|
||||
(partner + base‑unit 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); non‑ADP units
|
||||
lack `x_fc_adp_delivery_date` (need an invoice/order‑date fallback anchor).
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
Extend `fusion_repairs`. No new module, no new top‑level 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/test‑runs without `fusion_claims` on local dev.
|
||||
|
||||
Reuse map: contract engine (extend), `fusion.technician.task` (booking target + availability +
|
||||
roll‑forward), `repair.order` (visit container/pricing/Poynt), inspection certificate (lift
|
||||
compliance), visit‑report 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 (1–6+).
|
||||
- `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 category‑based** (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.
|
||||
|
||||
**Per‑product 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 per‑visit 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
|
||||
non‑ADP 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 (one‑time wizard, idempotent)
|
||||
`fusion.repair.maintenance.backfill.wizard`:
|
||||
- **Scan** historical `sale.order.line` for products whose category/product is maintenance‑enabled and
|
||||
were delivered. **Two unit‑identity regimes**, because lifts carry no serials (§3.3):
|
||||
- **Serial‑tracked** (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**.
|
||||
- **Non‑serial** (lifts — stair/porch/VPL/lift‑chair): do **NOT** require a serial. One contract per
|
||||
**base‑unit line**, **dedup by (partner + maintainable product + source sale line)**. The per‑product
|
||||
`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 add‑ons.
|
||||
- **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.
|
||||
- **Dry‑run first**: produce a report (counts by category, # new vs already‑enrolled, # 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 self‑serve (no login)
|
||||
1. Reminder email (existing branded template, **+ fee line added**) → tokenized link.
|
||||
2. Public slot‑picker 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 ~2–3 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:
|
||||
- **Re‑validate** the slot is still free (gap check) — if taken/expired, re‑render slots with a
|
||||
gentle notice (prevents double‑booking).
|
||||
- Create a `fusion.technician.task` (`task_type='maintenance'`) on that slot, **assigned to the
|
||||
qualified tech** (auto‑assignment by availability+skill), linked to the contract.
|
||||
- Spawn/link the maintenance‑type `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. **No‑slot 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
|
||||
slot‑picker 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
|
||||
slot‑picker 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: **pay‑at‑door via `fusion_poynt`**
|
||||
(existing `action_collect_payment` on the repair) or invoice after the visit.
|
||||
- Recurring revenue = one priced visit per cycle; the roll‑forward arms the next cycle automatically.
|
||||
(Pre‑paid 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 visit‑report 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 **roll‑forward**: `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` re‑enters the 30‑day band.
|
||||
|
||||
## 10. Office follow‑up crons (toggle‑gated, 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`.
|
||||
- Per‑row **savepoint** isolation inside the cron loop (no `cr.commit()` in tests — CLAUDE.md #14).
|
||||
|
||||
## 11. Out of scope (v1 — YAGNI)
|
||||
- SMS reminders / two‑way SMS booking (needs `fusion_ringcentral`).
|
||||
- Logged‑in `/my/equipment` client portal (X5).
|
||||
- Pre‑paid annual maintenance‑plan auto‑upsell at booking.
|
||||
- Full multi‑stop route optimization / batching (we use per‑tech availability + proximity ranking,
|
||||
not a global optimizer).
|
||||
- ADP funder re‑billing of maintenance (maintenance is private‑pay flat fee in v1).
|
||||
|
||||
## 12. Error handling & edge cases
|
||||
- **Double‑booking:** re‑validate the gap at confirm; lose the race → re‑show slots.
|
||||
- **Token:** per‑cycle regeneration; invalid/expired/already‑booked → friendly pages (exist, extend).
|
||||
- **No qualified tech / no slots:** callback fallback, not an error page.
|
||||
- **Backfill:** dry‑run + report; strict serial dedup; stagger; fallback anchor chain; never email on
|
||||
dry‑run.
|
||||
- **Missing data:** units with no device_type/category → excluded from auto‑backfill, 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 Community‑testable** on
|
||||
`odoo-modsdev`. `TransactionCase` coverage:
|
||||
- Contract spawn on `sale.order` confirm (enabled vs disabled category; quantity; idempotency).
|
||||
- Backfill wizard: **two‑regime dedup** (serial for wheelchairs; partner+product+line for lifts), accessory‑line exclusion, stagger, dry‑run produces no records, anchor fallback.
|
||||
- Booking: slot list comes from real gaps; confirm creates task+repair+visit; **double‑book guard**;
|
||||
no‑slot fallback.
|
||||
- Roll‑forward on completion: dates advance, band reset, **token regenerated**, visit → done.
|
||||
- Crons: reminder bands; unbooked/overdue follow‑ups (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 rate‑card 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 dry‑run → 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 visit‑report wizard's current fields/flow before extending it with the checklist.
|
||||
- The inspection‑certificate issue API (how M1 creates a certificate) for the lift link.
|
||||
- **Lift base sized** (§3.3): ~254 stairlift + ~30 porch/VPL + ~41 lift‑chair 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 → maintenance‑category 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. **Technician‑aware booking** (slot‑picker page + controller on `fusion_tasks` availability; task/repair/visit creation; double‑book guard; office action; token regen) + tests — the largest unit.
|
||||
5. **Maintenance visit log + checklist** (model, per‑category seed, visit‑report‑wizard extension, inspection‑cert link) + tests.
|
||||
6. **Backfill wizard** (scan/dedup/stagger/dry‑run; fusion_claims soft bridge) + tests.
|
||||
7. **Office follow‑up crons** (unbooked/overdue) + tests.
|
||||
8. **Deploy + configure + backfill** on Westin.
|
||||
@@ -1832,3 +1832,35 @@ When adding a new admin config, drop it into the right Configuration folder:
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## Partial Order Handling — parts fanning across stages (shipped 2026-06-02)
|
||||
|
||||
A 50-part job can have parts at several stages at once (10 Masking, 20 Plating, 20 Baking). The data layer always supported this (`fp.job.step.qty_at_step` = live parked count, computed from `fp.job.step.move` rows); 2026-06-02 made it **visible and operable**. Spec: [`docs/superpowers/specs/2026-06-02-shopfloor-partial-order-handling-design.md`](docs/superpowers/specs/2026-06-02-shopfloor-partial-order-handling-design.md). Versions: `fusion_plating 19.0.22.2.0`, `fusion_plating_jobs 19.0.11.6.0`, `fusion_plating_shopfloor 19.0.36.2.0`. Tracking model = **fluid quantities per stage** for normal flow + existing hold/scrap/rework records for exceptions (no new model, no migration). Close behaviour = **wait to reconverge** (the lifecycle is unchanged; the diverged subset keeps the job open via the existing `qty_done + qty_scrapped == qty` gate).
|
||||
|
||||
**Durable gotchas (non-obvious):**
|
||||
|
||||
1. **The plant kanban emits one card PER (job, stage), keyed by a composite `"{job_id}:{area}"`** — NOT one card per job. `cards` is a dict of composite-key → presence payload; a split job lists its key in several `columns[].card_ids`. See `_job_presences` / `_render_presence` in `plant_kanban.py`. A job with all parts at one stage yields exactly ONE presence (identical to the old board). The PRIMARY presence (active-step column) keeps the full job-level `card_state`; SECONDARY presences derive a simpler state from their own focus step (`_secondary_card_state`). Anything reading the board payload must handle composite keys + multi-column jobs. **A presence is emitted ONLY where parts physically are (`qty_at_step > 0`, incl. the first-active seed) OR a step is `in_progress`/`paused` — NEVER for a merely `ready`/`pending` future step.** These recipes seed EVERY downstream step to `ready` at job creation (not `pending`), so keying presence off `ready` made one job show in every not-yet-started stage at once (WO-30061 bug, fixed 2026-06-02). The old single-card board masked this because `active_step_id` picked just one. Strict sequential progress falls out for free: the `qty_at_step` seed always sits on the lowest-sequence non-terminal step and advances as each completes — so don't add `ready` back to the presence condition.
|
||||
|
||||
2. **`fp.job.step` has NO `qty_done` / `qty_scrapped` fields.** Those live on `fp.job`. The Move controller previously read `from_step.qty_done - from_step.qty_scrapped` for "available to move" → always 0 → the partial-move path was effectively dead. The source of truth for "parts parked here" is **`qty_at_step`** (move preview/commit + rack moves all read it now). Never reintroduce `step.qty_done`.
|
||||
|
||||
3. **The Move Parts dialog was only wired into the DEPRECATED `shopfloor_tablet.js`** — the live `fp_job_workspace` had no move/advance action, so operators literally could not move partial parts. The "Send → <next>" action now lives in `job_workspace.js` (`getStepActions` advance descriptor → `onAdvanceStep` → `FpMovePartsDialog`). The dialog itself was slimmed (qty steppers, no keyboard; Transfer Type + To Location collapsed behind "More options"). If you add another operator surface, wire the advance action there too.
|
||||
|
||||
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves.
|
||||
|
||||
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream.
|
||||
|
||||
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty − qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
|
||||
|
||||
**Verification:** the plating modules can't be installed on the local Community dev DB (missing enterprise deps — same reason `fusion_plating` shows `installed=0` in `modsdev`/`fusion-dev`). Static checks done: pyflakes (Python), lxml parse (XML), `node --check` as `.mjs` (JS — `node --check` on a `.js` errors with "Cannot use import statement outside a module"; copy to `/tmp/x.mjs` first). Dynamic tests + browser check require an installed env (entech / odoo-trial).
|
||||
|
||||
---
|
||||
|
||||
## Dark-mode SCSS gotchas — shop-floor dialogs/components (fixed 2026-06-02)
|
||||
|
||||
Operators reported invisible (dark-on-dark) text in the workspace + "Cannot Finish Step" dialog under Odoo dark mode. Root causes + the rules:
|
||||
|
||||
1. **`var(--text-secondary, #333)` is a MADE-UP variable — it does not exist in Odoo, so it ALWAYS falls back to the hardcoded dark hex → invisible on dark backgrounds.** It was used 33× across `job_workspace.scss` + 5 component stylesheets. The real, dark-aware secondary-text variable is **`var(--bs-secondary-color)`** (CLAUDE.md rule 9 lists it). Never use `--text-secondary` / `--text-primary` / `--card-bg` etc. — those aren't Odoo vars.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# Shop Floor — Partial Order Handling (design)
|
||||
|
||||
- **Date:** 2026-06-02
|
||||
- **Status:** Approved (design), pending implementation plan
|
||||
- **Modules touched:** `fusion_plating_shopfloor`, `fusion_plating_jobs`, `fusion_plating` (core step model)
|
||||
- **Author context:** Nexa Systems / EN Technologies (entech), Odoo 19
|
||||
|
||||
## Problem
|
||||
|
||||
A plating job runs 50 parts, but parts physically fan out across stages — 10 at Masking, 20 at Plating, 20 at Baking — because the shop processes in racks/waves through tank-limited stations. Today the Shop Floor board collapses each job to **one card in one column** (the single `active_step_id.area_kind`), so an operator standing at Baking has no way to see "10 of this job's parts are here, waiting for me." Operators need to handle the spread-out parts through the different stages, and it must be **easy** (minimal typing, minimal guessing) and **production-ready**.
|
||||
|
||||
## Goals
|
||||
|
||||
- Operators can **see** a job's parts at every stage where they physically are.
|
||||
- Operators can **advance** a subset to the next stage with near-zero friction.
|
||||
- Failed/held/rework subsets are tracked and visible (not silently lost).
|
||||
- The change is **additive** — it must not redesign the board, the quantity model, the workspace entry contract, or the close/cert/ship/invoice lifecycle.
|
||||
|
||||
## Non-goals (explicitly out of scope for v1)
|
||||
|
||||
- **Partial shipping** — shipping the good parts now and the rest later (split CoCs, split deliveries, partial invoicing). The job reconverges and ships once.
|
||||
- **Named sub-lots** — persistent per-group identity/labels for interchangeable parts.
|
||||
- **Manager-initiated job split** — closing a job at a shipped quantity and spawning a follow-on job for the remainder. Notable as a possible future add-on; operators never touch it.
|
||||
- Multi-cert / multi-delivery per job; per-delivery quantity tracking.
|
||||
|
||||
---
|
||||
|
||||
## Current-state baseline (verified in code)
|
||||
|
||||
**The data layer already supports partial quantities.**
|
||||
- `fp.job.step.qty_at_step` — live "parts parked here" = `sum(incoming moves) − sum(outgoing moves)`, with a first-step seed (the earliest non-terminal step implicitly holds full `job.qty` before any move). Compute at `fusion_plating/models/fp_job_step.py::_compute_qty_at_step`.
|
||||
- `fp.job.step.move` (`fusion_plating/models/fp_job_step_move.py`) — chain-of-custody row per move; `qty_moved` already lets an operator move a subset; `transfer_type` ∈ {step, hold, scrap, rework, split, return}.
|
||||
- `move_parts_commit` (`fusion_plating_shopfloor/controllers/move_controller.py`) already moves a subset and advances `qty_at_step_start/finish`.
|
||||
- `button_finish` already refuses to close a stage while parts are parked there with a downstream step waiting (`fusion_plating/models/fp_job_step.py`).
|
||||
|
||||
**The gap is visibility + interaction.**
|
||||
- `fp.job.active_step_id` picks **one** step by priority (in_progress > paused > ready > pending) — `fusion_plating_jobs/models/fp_job.py::_compute_active_step_id`.
|
||||
- The plant kanban places the whole job in the **one** column of that step — `fusion_plating_shopfloor/controllers/plant_kanban.py::_resolve_card_area` / `_render_card`; the board payload is `cards` (dict keyed by `str(job.id)`) + `columns[].card_ids` (list of job ids); OWL renders one `FpPlantCard` per id (`plant_kanban.xml`, `components/plant_card.js`).
|
||||
- The Move dialog (`move_parts_dialog.{js,xml}`) exposes Transfer Type (6 options) and To Location (6 options) as always-visible dropdowns and uses a raw numeric input for qty.
|
||||
|
||||
**The close lifecycle assumes one job = one quantity = one CoC = one delivery.**
|
||||
- `button_mark_done` (`fusion_plating_jobs/models/fp_job.py`) gates on step-completion, bake, `qty_done + qty_scrapped == qty`, receiving reconciliation, and QC, then calls `_fp_create_delivery()` + `_fp_create_certificates()` (one each).
|
||||
- Auto-advance: last step finished → `awaiting_cert` (if a cert is required) or `awaiting_ship`; cert issue advances `awaiting_cert → awaiting_ship`; cert void regresses. There is an order-level "ship together" constraint (`_fp_order_ship_state`).
|
||||
- No partial-shipment infrastructure exists anywhere.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Fork | Decision |
|
||||
|---|---|
|
||||
| Tracking model | **C — fluid quantities per stage + existing records for exceptions.** Normal flow uses `qty_at_step`; failed/held/rework subsets ride the existing hold/scrap/rework records. No new core model. |
|
||||
| Board representation | **Option 2 — a card per stage-presence.** A job appears as a card in every stage where it has parts; composite `job:area` card keys. Unsplit jobs render identically to today. |
|
||||
| Operator move interaction | **Easy-advance.** One intent-named "Send to [next] →" action with everything defaulted; steppers / rack-tap instead of a keyboard; Hold/Scrap/Rework as distinct buttons. |
|
||||
| Downstream "light-up" | **Auto-ready on arrival + qty-aware predecessor gate + auto-finish source on drain.** No auto-start (labour accuracy). |
|
||||
| Close behaviour | **Option B — wait to reconverge.** The close/cert/ship/invoice lifecycle is unchanged; the diverged subset keeps the job open via the existing reconciliation gate. |
|
||||
|
||||
---
|
||||
|
||||
## Detailed design
|
||||
|
||||
### A. Data model
|
||||
|
||||
No new model and no new core fields are required. The feature reuses:
|
||||
- `fp.job.step.qty_at_step` (live parked count) — already the source of truth.
|
||||
- `fp.job.step.move` + `transfer_type` — already records advance/hold/scrap/rework.
|
||||
- `fusion.plating.quality.hold` — already represents "N parts of this job are held" (the tracked exception group).
|
||||
|
||||
### B. Board — card per stage-presence
|
||||
|
||||
**Backend (`plant_kanban.py`).** Replace the "one area per job" bucketing with per-stage presences. For each in-flight job:
|
||||
|
||||
1. Group the job's **non-terminal** steps by `area_kind` (note: many steps can share an area, e.g. all wet-line steps roll into `plating` per the existing column map).
|
||||
2. For each area, compute:
|
||||
- `qty_here` = sum of `qty_at_step` across that area's non-terminal steps.
|
||||
- `focus_step_id` = the most-actionable step in the area (in_progress > paused > ready > pending) — used for the tap target and the per-presence state.
|
||||
3. **Emit a presence** for an area when `qty_here > 0` **or** any step in the area is in_progress/paused/ready (a started-but-drained stage still shows until finished).
|
||||
4. **Exception presences:** a quality hold on N parts surfaces as a flagged presence ("🔴 N on hold") in the stage it is associated with. *Linkage detail* (hold→step/area vs. hold→job + area inference) is finalized in the plan after reading the hold model; fallback is to attach the held indicator to the job's furthest-along presence. Rework re-entering an earlier stage shows as a normal presence there (optionally flagged "rework"). Scrap is not a presence (counted in `qty_scrapped`, shown in job totals only).
|
||||
|
||||
**Payload schema.** `cards` becomes a dict keyed by composite `"{job_id}:{area}"`; `columns[].card_ids` lists composite keys; a split job lists one key in each occupied column. Each presence payload is the existing card payload **plus**: `area_kind`, `qty_here`, `job_qty`, `focus_step_id`, and a per-presence `card_state` / `state_chip` / `operator` / `step_name` derived from that area's focus step (not the job's global `active_step_id`). Reuse the existing helpers (`_state_chip`, `_compute_tags`, `_due_label`, `_icons`).
|
||||
|
||||
- The job-level `card_state` / `active_step_id` computes stay **unchanged** — they still drive server-side filters and KPI counts.
|
||||
- **KPIs** dedupe by `job_id` (count distinct jobs, not presences).
|
||||
- **mini_timeline** on every presence still shows the **whole-job** spread, so the big picture is visible from any stage.
|
||||
|
||||
**Frontend (`plant_kanban.xml`, `components/plant_card.js`).**
|
||||
- The render loop (`t-foreach columns → card_ids → FpPlantCard`) is **unchanged** — `t-key="card_id"` already works with composite keys.
|
||||
- `FpPlantCard` gains a "**{qty_here} of {job_qty}**" line; `onCardClick` passes `focus_step_id` into the workspace (the workspace already accepts `focus_step_id` — see the FP-STEP scan path in `plant_kanban.js`).
|
||||
- `filteredCardIds` is unchanged (it filters on card payload fields; each presence carries the same searchable fields, so all presences of a matching job show).
|
||||
|
||||
**Unsplit invariant.** A job with all parts at one stage produces exactly one presence → one card in one column → byte-for-byte identical to today. Multi-card behaviour only activates on an actual split.
|
||||
|
||||
### C. Operator flow — easy advance
|
||||
|
||||
**Primary action: "Send to [next stage] →".** Surfaced on the stage presence (card/workspace). Opens a slim confirm pre-set to:
|
||||
- `to_step` = next step in recipe sequence (no guessing).
|
||||
- `qty` = all parked here (`qty_at_step`); adjustable with **± steppers + an "All" preset** — the keyboard never opens for the common case.
|
||||
- `transfer_type` = `step` (hidden); `to_location` = `global` (hidden behind **More options**); `to_tank` = recipe default (existing behaviour).
|
||||
- Compliance prompts render only when the recipe author marked them (unchanged), using pickers, not free text.
|
||||
|
||||
**Racked parts:** when parts are on racks, advancing is **rack-granular** — tap the rack(s) to send, moving that rack's count atomically via the existing **Move Rack** flow. No quantity typed at all.
|
||||
|
||||
**Exceptions get their own intent-named buttons** (replacing the Transfer Type dropdown for everyday use):
|
||||
- **Hold** — pick qty + reason from a picker → reuses the existing hold composer → the subset becomes the tracked held group; the rest stay put.
|
||||
- **Scrap** — qty + reason → counted in `qty_scrapped`.
|
||||
- **Rework** — qty + destination earlier stage → a `transfer_type='rework'` move.
|
||||
|
||||
The full generic Move dialog remains available behind "More options" — we slim the default path, we don't delete capability.
|
||||
|
||||
### D. State machine — the invisible "light-up"
|
||||
|
||||
Three small, additive behaviours so operators never manage stage state manually:
|
||||
|
||||
1. **Auto-ready on arrival.** In `_do_move_parts_commit` (and `_do_move_rack_commit`): after the move + counter advance, if `to_step.state == 'pending'`, set it to `ready`. Never downgrade a step. Result: the receiving operator's column immediately shows a "{qty} of {job_qty} · Ready to start" card with no action by anyone.
|
||||
2. **Qty-aware predecessor gate.** A step that has **real parts parked** (an incoming move with `from_step_id != step` and `qty_moved > 0`) is startable regardless of whether upstream steps are fully done. Applied consistently in `_fp_should_block_predecessors` (used by both `button_start` and the Move dialog's `_blockers_for_move`). Rationale: once parts physically arrive, the predecessor lock is moot.
|
||||
3. **Auto-finish the source on drain-to-zero.** When a move drains `from_step.qty_at_step` to 0 and the step is in_progress with no remaining work, finish it via the existing finish path (generalize the `action_complete_one_to_next` drain→finish pattern to bulk moves). One fewer tap.
|
||||
|
||||
Deliberately **no auto-start** of the receiving step — `button_start` stays an explicit tap because it begins the labour timer (keeps cost/time accurate and avoids the phantom-timer problem the S16 cron already fights).
|
||||
|
||||
**Correctness fix.** The move preview currently derives availability from `from_step.qty_done − from_step.qty_scrapped`; change it to read `qty_at_step` (the live parked count shown on the card) so the pre-filled number **always matches what the operator sees**.
|
||||
|
||||
### E. Close — wait to reconverge (Option B)
|
||||
|
||||
The close/cert/ship/invoice lifecycle is **unchanged**:
|
||||
- `button_mark_done` gates, `_fp_create_certificates` (one CoC), `_fp_create_delivery` (one delivery), and the `awaiting_cert`/`awaiting_ship` transitions stay as-is.
|
||||
- The diverged subset keeps the job open **for free**: with parts in hold/rework, `qty_done + qty_scrapped` cannot equal `qty`, so the existing reconciliation gate simply won't let the job close until those parts resolve (reworked → done, or scrapped → counted). This is the correct behaviour, already enforced.
|
||||
- Normal jobs reconverge at Final Inspection (all parts back in one count) and close once.
|
||||
|
||||
**Hardening (additive).** At close, `qty_done` auto-fill should derive from the quantity that actually **completed the final runnable step** (via the last step's `qty_at_step_finish` / move chain), not assume `job.qty`. Keep the existing reconciliation gate — just feed it an honest number when parts fanned out.
|
||||
|
||||
### F. Reconciliation invariant
|
||||
|
||||
At any time: `job.qty = (parts parked across all stages) + (on hold) + (in rework) + (scrapped) + (completed/shipped)`. The board surfaces the first three as presences; `qty_scrapped` and the final count feed the existing close gate `qty_done + qty_scrapped == qty`.
|
||||
|
||||
---
|
||||
|
||||
## Blast radius
|
||||
|
||||
**Changes**
|
||||
- `plant_kanban.py` — per-stage presence rendering + composite keys + KPI dedupe (localized to `_render_card` + the bucketing loop; reuses all existing chip/tag/icon helpers).
|
||||
- `components/plant_card.js` + `plant_kanban.xml` — "{qty} of {job_qty}" line; tap passes `focus_step_id` (~a few lines).
|
||||
- `move_parts_dialog.{js,xml}` — slim "Advance" default (steppers, "All", hidden advanced fields); Hold/Scrap/Rework intent buttons; full dialog behind "More options".
|
||||
- `move_controller.py` — auto-ready on arrival; auto-finish on drain; preview availability/pre-fill from `qty_at_step`.
|
||||
- `fp_job_step.py` — qty-aware predecessor gate; bulk drain→finish helper.
|
||||
- `fp_job.py` — `qty_done` derivation at close (hardening only).
|
||||
|
||||
**Does NOT change**
|
||||
- The quantity model (`qty_at_step`, `fp.job.step.move`).
|
||||
- The OWL component tree (FpTabletLock → header → board → columns → FpPlantCard → FpMiniTimeline), polling, filters, search, station pairing, QR.
|
||||
- The Job Workspace entry contract (`focus_step_id` already supported).
|
||||
- Holds / certs / bake windows / QC.
|
||||
- The close → cert → ship → invoice lifecycle (`button_mark_done`, `_fp_create_certificates`, `_fp_create_delivery`, awaiting_cert/awaiting_ship, order "ship together").
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **No recipe / no steps:** orphan fallback unchanged (Receiving column).
|
||||
- **Multiple steps in one area** (wet-line → plating): presence aggregates `qty_here` across them; `focus_step_id` is the most-actionable.
|
||||
- **First-step seed:** before any move, the whole qty sits at the first stage → one presence = today's single card.
|
||||
- **Recombination:** 30 + 20 both reaching Baking simply read as `qty_here = 50` at that stage (fluid quantities merge automatically).
|
||||
- **Held parts with no step linkage:** held indicator attaches to the job's furthest-along presence (plan-time detail).
|
||||
- **Search match:** every presence of a matching job shows across its columns (each carries the same searchable fields).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit:** `qty_at_step` across a multi-stage split; qty-aware predecessor (parts-present → startable); auto-ready pending→ready on move; auto-finish source on drain-to-zero; `qty_done` derivation at close; reconciliation gate still fires with held parts.
|
||||
- **Integration:** board emits N presences for a split job and exactly 1 for an unsplit job; KPIs dedupe by job; tapping a presence opens the workspace on the right step.
|
||||
- **Persona walk:** operator finishes a subset at Plating → taps Send → receiver sees a "Ready" card at Baking with the right qty; 5 parts go on Hold → job stays open until resolved → reworked/closed → one cert, one delivery.
|
||||
|
||||
## Deployment
|
||||
|
||||
Per-module `__manifest__.py` version bumps so the SCSS/asset bundle busts and any data reloads. Entech is native Odoo (LXC 111, DB `admin`) — standard `-u fusion_plating_shopfloor,fusion_plating_jobs,fusion_plating` upgrade. No data migration required (no new persistent fields; additive behaviours only).
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.22.1.0',
|
||||
'version': '19.0.22.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.11.5.0',
|
||||
'version': '19.0.11.6.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -2074,11 +2074,27 @@ class FpJob(models.Model):
|
||||
# the operator reconciles by hand. Mirrors the receiving
|
||||
# `_update_job_qty_received` pattern: server fills the
|
||||
# obvious default, operator owns the edge cases.
|
||||
if (not job.qty_done and not job.qty_scrapped
|
||||
# Partial-order handling (2026-06-02): surface scrap that
|
||||
# was recorded through the Move log (transfer_type='scrap')
|
||||
# into qty_scrapped, so the reconciliation + cert qty stay
|
||||
# honest even when scrap was done from the tablet Move
|
||||
# dialog rather than the qty_scrapped field. Only when the
|
||||
# field hasn't been set by hand.
|
||||
scrap_moves = job._fp_scrapped_via_moves()
|
||||
if scrap_moves and not job.qty_scrapped:
|
||||
job.qty_scrapped = scrap_moves
|
||||
# Clean-close auto-fill: derive the good (done) count from
|
||||
# what physically came in minus scrap, instead of blindly
|
||||
# assuming the whole order completed (which over-counts when
|
||||
# parts were scrapped mid-line). Skips when the operator
|
||||
# already typed qty_done, or when visual rejects make the
|
||||
# split non-obvious — then the gate below makes them
|
||||
# reconcile by hand.
|
||||
if (not job.qty_done
|
||||
and not (job.qty_visual_inspection_rejects or 0)
|
||||
and job.qty_received
|
||||
and abs(job.qty_received - job.qty) < 0.0001):
|
||||
job.qty_done = job.qty
|
||||
job.qty_done = job.qty - (job.qty_scrapped or 0)
|
||||
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
|
||||
if abs(accounted - job.qty) > 0.0001:
|
||||
raise UserError(_(
|
||||
@@ -2439,6 +2455,19 @@ class FpJob(models.Model):
|
||||
fp_skip_step_gate=True,
|
||||
).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):
|
||||
"""Auto-advance in_progress jobs whose recipe steps are all
|
||||
terminal. Called from fp.job.step.button_finish post-super().
|
||||
|
||||
@@ -54,12 +54,37 @@ class FpJobStep(models.Model):
|
||||
# leak permissive behaviour through a related-field None.
|
||||
if not self.job_id:
|
||||
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
|
||||
if recipe_seq:
|
||||
return not self.parallel_start
|
||||
# Free-flow recipe — only the legacy per-step flag still gates.
|
||||
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):
|
||||
"""True when an earlier-sequence step on the same job is not yet
|
||||
in a terminal state. Composes with _fp_should_block_predecessors
|
||||
@@ -86,6 +111,10 @@ class FpJobStep(models.Model):
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state',
|
||||
'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):
|
||||
for step in self:
|
||||
@@ -217,6 +246,9 @@ class FpJobStep(models.Model):
|
||||
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
||||
'job_id.enforce_sequential',
|
||||
'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):
|
||||
for step in self:
|
||||
@@ -652,6 +684,42 @@ class FpJobStep(models.Model):
|
||||
).sorted('sequence')
|
||||
return candidates[:1] or self.env['fp.job.step']
|
||||
|
||||
def _fp_try_autofinish_on_drain(self):
|
||||
"""Best-effort auto-finish when a step has drained to zero parked
|
||||
parts (2026-06-02 partial-order handling).
|
||||
|
||||
Called by the Move controller after a bulk move commits. When the
|
||||
last parts leave an in_progress step it should close itself — one
|
||||
fewer tap for the operator. But finishing runs the full gate chain
|
||||
(required inputs, sign-off, contract review, receiving, and the
|
||||
post-shop close gates on the last step). If any gate isn't
|
||||
satisfied we must NOT fail the move that already succeeded — so we
|
||||
swallow the UserError and leave the step in_progress for the
|
||||
operator to finish manually (the board will show it "running, 0
|
||||
here", which reads as "finish me").
|
||||
|
||||
Only fires for steps that had REAL incoming parts — never an
|
||||
untouched first-step seed. Returns True if the step finished.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'in_progress':
|
||||
return False
|
||||
if not self._fp_has_real_incoming():
|
||||
return False
|
||||
# qty_at_step is a non-stored compute off the move rows — force a
|
||||
# re-read so we see the just-committed outgoing move.
|
||||
self.invalidate_recordset(['qty_at_step'])
|
||||
if self.qty_at_step != 0:
|
||||
return False
|
||||
try:
|
||||
self.button_finish()
|
||||
return True
|
||||
except UserError:
|
||||
# Gates still pending (missing prompts / sign-off / etc.) —
|
||||
# leave the step in_progress for a manual finish. The move
|
||||
# itself stands.
|
||||
return False
|
||||
|
||||
def _fp_has_uncaptured_step_inputs(self):
|
||||
"""True when the recipe step has REQUIRED step_input prompts
|
||||
whose values haven't been recorded yet.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.36.1.1',
|
||||
'version': '19.0.36.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||
'description': """
|
||||
|
||||
@@ -147,7 +147,12 @@ class FpTabletMoveController(http.Controller):
|
||||
Step = request.env['fp.job.step']
|
||||
from_step = Step.browse(from_step_id)
|
||||
to_step = Step.browse(to_step_id)
|
||||
qty = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
|
||||
# Available-to-move = parts currently parked here (qty_at_step —
|
||||
# the exact number the operator sees on the card). The old
|
||||
# qty_done − qty_scrapped read referenced step fields that don't
|
||||
# exist on fp.job.step (always 0), which is why the move path was
|
||||
# effectively unusable. See partial-order-handling design.
|
||||
qty = from_step.qty_at_step or 0
|
||||
return {
|
||||
'ok': True,
|
||||
'qty_available': qty,
|
||||
@@ -186,7 +191,7 @@ class FpTabletMoveController(http.Controller):
|
||||
if hard:
|
||||
raise UserError(hard[0]['message'])
|
||||
|
||||
qty_avail = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
|
||||
qty_avail = from_step.qty_at_step or 0
|
||||
move = Move.create({
|
||||
'job_id': from_step.job_id.id,
|
||||
'from_step_id': from_step.id,
|
||||
@@ -214,6 +219,28 @@ class FpTabletMoveController(http.Controller):
|
||||
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
||||
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
|
||||
ctx = request.env.context
|
||||
bypass_flags = [
|
||||
@@ -279,7 +306,7 @@ class FpTabletMoveController(http.Controller):
|
||||
'batches': [
|
||||
{
|
||||
'step_id': s.id,
|
||||
'qty': (s.qty_done or 0) - (s.qty_scrapped or 0),
|
||||
'qty': s.qty_at_step or 0,
|
||||
'part_number': (s.job_id.product_id.default_code or ''),
|
||||
'wo_number': s.job_id.name or '',
|
||||
}
|
||||
@@ -343,7 +370,7 @@ class FpTabletMoveController(http.Controller):
|
||||
|
||||
moves = []
|
||||
for batch in Step.search([('rack_id', '=', rack.id)]):
|
||||
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0)
|
||||
qty = batch.qty_at_step or 0
|
||||
move = Move.create({
|
||||
'job_id': batch.job_id.id,
|
||||
'from_step_id': batch.id,
|
||||
@@ -353,9 +380,19 @@ class FpTabletMoveController(http.Controller):
|
||||
'rack_id': rack.id,
|
||||
'to_tank_id': to_tank_id or False,
|
||||
})
|
||||
batch.qty_at_step_finish = qty
|
||||
batch.qty_at_step_finish = (batch.qty_at_step_finish or 0) + qty
|
||||
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
||||
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'
|
||||
return {'move_ids': moves, 'count': len(moves)}
|
||||
|
||||
@@ -10,7 +10,7 @@ docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, datetime
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.http import request
|
||||
@@ -110,19 +110,28 @@ class PlantKanbanController(http.Controller):
|
||||
|
||||
jobs = Job.search(domain, limit=500)
|
||||
|
||||
# Bucket by area_kind of the active step (or 'receiving' when no
|
||||
# active step yet — matches the contract_review / no_parts states
|
||||
# that live in Receiving column per spec §3 D5).
|
||||
# Partial-order handling (2026-06-02): a job shows up as a card in
|
||||
# EVERY stage where it currently has parts (a "presence"), not just
|
||||
# the single active-step column. Cards are keyed by a composite
|
||||
# "{job_id}:{area}" so one job can appear in several columns. A job
|
||||
# whose parts are all at one stage produces exactly one presence —
|
||||
# byte-for-byte identical to the previous one-card-per-job board.
|
||||
cards = {}
|
||||
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
|
||||
for job in jobs:
|
||||
area = _resolve_card_area(job)
|
||||
cards_by_area.setdefault(area, []).append(job.id)
|
||||
cards[str(job.id)] = _render_card(job, paired)
|
||||
active_area = (job.active_step_id.area_kind
|
||||
if job.active_step_id else _resolve_card_area(job))
|
||||
for area, focus_step, qty_here in _job_presences(job):
|
||||
key = '%s:%s' % (job.id, area)
|
||||
cards[key] = _render_presence(
|
||||
job, area, focus_step, qty_here,
|
||||
area == active_area, paired,
|
||||
)
|
||||
cards_by_area.setdefault(area, []).append(key)
|
||||
|
||||
# Sort within each column by priority then due date
|
||||
for area in cards_by_area:
|
||||
cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)]))
|
||||
cards_by_area[area].sort(key=lambda k: _sort_key(cards[k]))
|
||||
|
||||
columns = [
|
||||
{
|
||||
@@ -251,21 +260,109 @@ def _resolve_card_area(job):
|
||||
return 'receiving'
|
||||
|
||||
|
||||
def _render_card(job, paired):
|
||||
"""Build the full card payload for one fp.job."""
|
||||
# Sudo the job recordset so cross-module field reads (sale.order,
|
||||
# fp.part.catalog, fusion.plating.customer.spec) don't AccessError
|
||||
# for low-privilege roles like Technician. The output is denormalized
|
||||
# display data; the underlying record visibility is controlled by the
|
||||
# caller's fp.job ACL (Technician can read all jobs).
|
||||
def _job_presences(job):
|
||||
"""Return the list of (area, focus_step, qty_here) presences for a job.
|
||||
|
||||
One entry per Shop Floor area where the job currently has parts parked
|
||||
OR an actionable (in_progress / paused / ready) step. This is what lets
|
||||
a split job appear in several columns at once. A job whose parts are
|
||||
all at one stage yields exactly ONE presence — byte-for-byte identical
|
||||
to the previous one-card-per-job board.
|
||||
"""
|
||||
job = job.sudo()
|
||||
Step = job.env['fp.job.step']
|
||||
# Post-shop + no-parts states are single-column, state-driven (mirrors
|
||||
# _resolve_card_area). No per-stage fan-out once the job has cleared
|
||||
# the line or hasn't received parts yet.
|
||||
if job.card_state == 'no_parts':
|
||||
return [('receiving', job.active_step_id, 0)]
|
||||
if job.state == 'awaiting_cert':
|
||||
return [('inspection', Step, 0)]
|
||||
if job.state == 'awaiting_ship':
|
||||
return [('shipping', Step, 0)]
|
||||
|
||||
open_steps = job.step_ids.filtered(
|
||||
lambda s: s.state not in ('done', 'skipped', 'cancelled')
|
||||
)
|
||||
by_area = {}
|
||||
for s in open_steps:
|
||||
by_area.setdefault(s.area_kind or 'plating', []).append(s)
|
||||
|
||||
presences = []
|
||||
for area, steps in by_area.items():
|
||||
qty_here = sum((s.qty_at_step or 0) for s in steps)
|
||||
# A stage shows ONLY where parts physically are (qty_here > 0 —
|
||||
# which includes the first-active step's qty_at_step seed) OR where
|
||||
# a step is actively being worked (in_progress / paused — e.g.
|
||||
# drained to zero but not yet finished). A merely `ready` / `pending`
|
||||
# step with NO parts is a FUTURE stage and must NOT show — otherwise
|
||||
# the job appears in every not-yet-started step at once (these
|
||||
# recipes seed all downstream steps to `ready`, so 6 ready steps =
|
||||
# 6 phantom cards; bug on WO-30061). Strict sequential progress
|
||||
# falls out for free because the qty_at_step seed always sits on the
|
||||
# lowest-sequence non-terminal step and advances as each completes.
|
||||
being_worked = any(
|
||||
s.state in ('in_progress', 'paused') for s in steps
|
||||
)
|
||||
if qty_here > 0 or being_worked:
|
||||
presences.append((area, _pick_focus_step(steps), qty_here))
|
||||
|
||||
if not presences:
|
||||
# Nothing parked and nothing actionable — fall back to the single
|
||||
# resolved column so the job never vanishes from the board.
|
||||
return [(_resolve_card_area(job), job.active_step_id, 0)]
|
||||
return presences
|
||||
|
||||
|
||||
def _pick_focus_step(steps):
|
||||
"""The most-actionable step in an area: in_progress > paused > ready >
|
||||
pending, lowest sequence within a state. Drives the presence card's
|
||||
step label, operator pill, and tap target (focus_step_id)."""
|
||||
ordered = sorted(steps, key=lambda s: s.sequence or 0)
|
||||
for state in ('in_progress', 'paused', 'ready', 'pending'):
|
||||
for s in ordered:
|
||||
if s.state == state:
|
||||
return s
|
||||
return ordered[0] if ordered else None
|
||||
|
||||
|
||||
def _secondary_card_state(step, paired):
|
||||
"""Card state for a NON-primary presence (a stage other than the job's
|
||||
active step). Derived purely from the focus step so the operator at
|
||||
that stage gets an honest 'running' / 'ready' chip. The PRIMARY
|
||||
presence keeps the full job-level card_state (holds, QC, bake, etc.)."""
|
||||
if not step:
|
||||
return 'ready'
|
||||
mine = bool(
|
||||
paired and step.work_centre_id
|
||||
and step.work_centre_id.id == paired.id
|
||||
)
|
||||
if step.state == 'in_progress':
|
||||
return 'running_mine' if mine else 'running'
|
||||
if step.state == 'paused':
|
||||
return 'running'
|
||||
# ready / pending → queued at this stage
|
||||
return 'ready_mine' if mine else 'ready'
|
||||
|
||||
|
||||
def _render_presence(job, area, step, qty_here, is_primary, paired):
|
||||
"""Build a card payload for one (job, stage) presence.
|
||||
|
||||
The PRIMARY presence (the job's active-step column) carries the full
|
||||
job-level card_state so every existing job-level signal (hold, QC,
|
||||
bake-due, sign-off, idle, post-shop) renders exactly as before.
|
||||
SECONDARY presences derive a simpler state from their own focus step.
|
||||
|
||||
Sudo the job so cross-module reads (sale.order, fp.part.catalog,
|
||||
customer.spec) don't AccessError for low-privilege roles (Rule 13m) —
|
||||
the output is denormalized display data; fp.job ACL gates visibility.
|
||||
"""
|
||||
job = job.sudo()
|
||||
step = job.active_step_id
|
||||
try:
|
||||
timeline = json.loads(job.mini_timeline_json or '[]')
|
||||
except (TypeError, ValueError):
|
||||
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
|
||||
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
|
||||
so = job.sale_order_id
|
||||
@@ -274,10 +371,11 @@ def _render_card(job, paired):
|
||||
if so and 'x_fc_po_number' in so._fields:
|
||||
po_number = so.x_fc_po_number or ''
|
||||
|
||||
# Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable)
|
||||
tags = _compute_tags(job, part, spec)
|
||||
|
||||
# Step + tank labels
|
||||
card_state = (job.card_state if is_primary
|
||||
else _secondary_card_state(step, paired))
|
||||
|
||||
step_name = step.name if step else _('—')
|
||||
step_seq = step.sequence if step else 0
|
||||
step_total = len(job.step_ids)
|
||||
@@ -285,23 +383,15 @@ def _render_card(job, paired):
|
||||
if step and step.work_centre_id:
|
||||
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
|
||||
|
||||
# State chip
|
||||
state_chip = _state_chip(job.card_state, step)
|
||||
state_chip = _state_chip(card_state, step)
|
||||
|
||||
# Operator pill (only when step has an assigned user)
|
||||
operator = None
|
||||
if step and 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)
|
||||
|
||||
# Due label
|
||||
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
|
||||
is_overdue = (
|
||||
bool(job.date_deadline)
|
||||
@@ -311,9 +401,17 @@ def _render_card(job, paired):
|
||||
|
||||
return {
|
||||
'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 '',
|
||||
'is_mine': job.card_state in ('ready_mine', 'running_mine'),
|
||||
'card_state': job.card_state or '',
|
||||
'is_mine': card_state in ('ready_mine', 'running_mine'),
|
||||
'card_state': card_state or '',
|
||||
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
|
||||
if job.date_deadline else None),
|
||||
'due_label': due_label,
|
||||
|
||||
@@ -76,6 +76,11 @@ class FpWorkspaceController(http.Controller):
|
||||
'kind': step.kind or 'other',
|
||||
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
|
||||
'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_name': step.assigned_user_id.name or '',
|
||||
'work_centre_name': step.work_centre_id.name or '',
|
||||
|
||||
@@ -60,11 +60,15 @@ export class FpPlantCard extends Component {
|
||||
onCardClick() {
|
||||
const c = this.props.card;
|
||||
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({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
target: "current",
|
||||
params: { job_id: c.job_id },
|
||||
params: { job_id: c.job_id, focus_step_id: c.focus_step_id || false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@ import { FpTabletLock } from "./tablet_lock";
|
||||
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||||
import { FpDamageDialog } from "./fp_damage_dialog";
|
||||
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
||||
import { FpMovePartsDialog } from "./move_parts_dialog";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, FpMovePartsDialog };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
@@ -225,7 +226,21 @@ export class FpJobWorkspace extends Component {
|
||||
if (step.override_excluded) return [];
|
||||
|
||||
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") {
|
||||
const adv = advanceAction();
|
||||
if (adv) actions.push(adv);
|
||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||
actions.push({ key: "pause", label: "Pause",
|
||||
@@ -240,6 +255,8 @@ export class FpJobWorkspace extends Component {
|
||||
if (step.state === "paused") {
|
||||
actions.push({ key: "resume", label: "Resume",
|
||||
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
||||
const adv = advanceAction();
|
||||
if (adv) actions.push(adv);
|
||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||
actions.push({
|
||||
@@ -281,6 +298,7 @@ export class FpJobWorkspace extends Component {
|
||||
case "mark_passed": return this.onMarkPassed(step);
|
||||
case "open_contract_review": return this.onOpenContractReview(step);
|
||||
case "start_with_rack": return this.onStartWithRack(step);
|
||||
case "advance": return this.onAdvanceStep(step);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +481,44 @@ export class FpJobWorkspace extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Partial-order advance (2026-06-02) -------------------------------
|
||||
// "Send to next →" — moves parts parked at this step to the next stage.
|
||||
// The destination auto-readies server-side (move_controller), so the
|
||||
// receiving operator sees a Ready card immediately; the source
|
||||
// auto-finishes when it drains to zero. Pure client-side next-step
|
||||
// resolution off the loaded step list — no extra RPC.
|
||||
|
||||
nextStepFor(step) {
|
||||
// The next stage parts flow into: lowest-sequence non-terminal step
|
||||
// after this one. Returns null at the end of the line (parts finish
|
||||
// in place there and close out at job mark-done).
|
||||
const steps = (this.state.data && this.state.data.steps) || [];
|
||||
const candidates = steps
|
||||
.filter((s) => s.sequence > step.sequence
|
||||
&& ["pending", "ready", "paused", "in_progress"].includes(s.state))
|
||||
.sort((a, b) => a.sequence - b.sequence);
|
||||
return candidates.length ? candidates[0] : null;
|
||||
}
|
||||
|
||||
onAdvanceStep(step) {
|
||||
const nxt = this.nextStepFor(step);
|
||||
if (!nxt) {
|
||||
this.notification.add(
|
||||
"This is the last stage — parts finish here and close out at job completion.",
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Open the slim Move dialog pre-set to advance to the next stage.
|
||||
// Qty defaults to all parked here (qty_at_step) via the preview
|
||||
// endpoint; the operator confirms or trims it with the steppers.
|
||||
this.dialog.add(FpMovePartsDialog, {
|
||||
fromStepId: step.id,
|
||||
toStepId: nxt.id,
|
||||
onCommit: async () => { await this.refresh(); },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
|
||||
// The receiver card at the top of the workspace lets the dock receiver
|
||||
// count boxes, set per-line received quantities + condition, log damage
|
||||
|
||||
@@ -40,6 +40,11 @@ export class FpMovePartsDialog extends Component {
|
||||
promptValues: {},
|
||||
blockers: [],
|
||||
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 () => {
|
||||
await this.loadPreview();
|
||||
@@ -152,4 +157,20 @@ export class FpMovePartsDialog extends Component {
|
||||
{ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,5 +26,5 @@ $_gate-text-hex: #b06600;
|
||||
.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; }
|
||||
.o_fp_gate_body { flex: 1; }
|
||||
.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; }
|
||||
.o_fp_gate_reason { color: var(--text-secondary, #666); font-size: 0.78rem; margin-top: 0.1rem; }
|
||||
.o_fp_gate_reason { color: var(--bs-secondary-color, #666); font-size: 0.78rem; margin-top: 0.1rem; }
|
||||
.o_fp_gate_jump { flex-shrink: 0; }
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
.o_fp_hc_row label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
}
|
||||
|
||||
@@ -34,18 +34,18 @@ $_kc-hover-hex: #f5f5f7;
|
||||
}
|
||||
|
||||
.o_fp_kcard_h2 {
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_qty {
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: 0.7rem; color: var(--text-secondary, #777);
|
||||
font-size: 0.7rem; color: var(--bs-secondary-color, #777);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_due { color: var(--text-secondary, #999); }
|
||||
.o_fp_kcard_due { color: var(--bs-secondary-color, #999); }
|
||||
|
||||
.o_fp_kcard_bar {
|
||||
height: 4px; background: rgba(0,0,0,0.08);
|
||||
@@ -74,7 +74,7 @@ $_kc-hover-hex: #f5f5f7;
|
||||
}
|
||||
|
||||
.o_fp_kcard_wc {
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--bs-secondary-color, #999);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ $_pin-dot-fill-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
|
||||
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
|
||||
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--bs-secondary-color, #666); text-align: center; }
|
||||
|
||||
.o_fp_pin_dots {
|
||||
display: flex;
|
||||
@@ -83,8 +83,8 @@ $_pin-dot-fill-hex: #1d1d1f;
|
||||
&:disabled { opacity: 0.5; cursor: wait; }
|
||||
}
|
||||
|
||||
.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(--text-secondary, #666); }
|
||||
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--bs-secondary-color, #666); }
|
||||
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--bs-secondary-color, #666); }
|
||||
|
||||
@keyframes o_fp_pin_shake_kf {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
|
||||
@@ -91,6 +91,21 @@
|
||||
.card-sub-em { color: $plant-text; font-weight: 600; }
|
||||
.card-meta { font-size: 11px; color: $plant-muted; }
|
||||
.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; }
|
||||
|
||||
.chip {
|
||||
|
||||
@@ -21,7 +21,7 @@ $_sig-canvas-border-hex: #d8dadd;
|
||||
|
||||
.o_fp_sig_ctx {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
}
|
||||
|
||||
.o_fp_sig_canvas {
|
||||
@@ -36,6 +36,6 @@ $_sig-canvas-border-hex: #d8dadd;
|
||||
|
||||
.o_fp_sig_hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--bs-secondary-color, #999);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_loading {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
|
||||
> 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_dot { color: var(--text-secondary, #999); }
|
||||
.o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #555); font-size: 0.95rem; }
|
||||
.o_fp_ws_dot { color: var(--bs-secondary-color, #999); }
|
||||
.o_fp_ws_cust, .o_fp_ws_part { color: var(--bs-secondary-color, #555); font-size: 0.95rem; }
|
||||
|
||||
.o_fp_ws_pill {
|
||||
background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%);
|
||||
@@ -97,7 +97,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
color: var(--bs-secondary-color, #555);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_bar_label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--bs-secondary-color, #888);
|
||||
margin-top: 0.35rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -237,7 +237,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--bs-secondary-color, #999);
|
||||
|
||||
> 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_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; }
|
||||
.o_fp_ws_step_num { color: var(--bs-secondary-color, #999); font-size: 0.78rem; min-width: 50px; }
|
||||
.o_fp_ws_step_name { font-weight: 600; }
|
||||
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; }
|
||||
.o_fp_ws_step_meta { color: var(--bs-secondary-color, #999); font-size: 0.78rem; margin-left: auto; }
|
||||
|
||||
.o_fp_ws_step_badge {
|
||||
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_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; }
|
||||
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--bs-secondary-color, #555); font-style: italic; }
|
||||
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||
|
||||
.o_fp_ws_step_excluded {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--bs-secondary-color, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary, #777);
|
||||
color: var(--bs-secondary-color, #777);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
}
|
||||
@@ -362,14 +362,14 @@ $_ws-text-hex: #1d1d1f;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #777);
|
||||
color: var(--bs-secondary-color, #777);
|
||||
}
|
||||
|
||||
.o_fp_ws_note .author { font-weight: 600; }
|
||||
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
|
||||
.o_fp_ws_note .body { color: var(--bs-secondary-color, #555); margin-top: 0.15rem; }
|
||||
|
||||
.o_fp_ws_empty_small {
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--bs-secondary-color, #999);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -417,7 +417,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #777);
|
||||
color: var(--bs-secondary-color, #777);
|
||||
}
|
||||
|
||||
.o_fp_ws_ship_fields {
|
||||
@@ -488,8 +488,8 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_rcv_status {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #78350f;
|
||||
background-color: color-mix(in srgb, #f59e0b 18%, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
@@ -507,7 +507,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #555);
|
||||
color: var(--bs-secondary-color, #555);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
@@ -529,7 +529,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -598,7 +598,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -644,7 +644,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_photos {
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
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_label { font-weight: 600; color: var(--text-secondary, #555); }
|
||||
.o_fp_dmg_label { font-weight: 600; color: var(--bs-secondary-color, #555); }
|
||||
.o_fp_dmg_req { color: #dc2626; }
|
||||
|
||||
.o_fp_dmg_pills {
|
||||
@@ -784,8 +784,8 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_timer_over {
|
||||
background: #fee2e2;
|
||||
color: #7f1d1d;
|
||||
background-color: color-mix(in srgb, #ef4444 16%, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -808,15 +808,18 @@ $_ws-text-hex: #1d1d1f;
|
||||
|
||||
.o_fp_finish_block_step {
|
||||
font-size: 1.1rem;
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
color: var(--bs-body-color);
|
||||
// Amber wash over the live theme bg — pale in light mode, dark-amber
|
||||
// in dark mode (the -bg-subtle/-text-emphasis BS vars aren't defined
|
||||
// in Odoo's bootstrap, so color-mix is the dark-aware path).
|
||||
background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg));
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_msg {
|
||||
color: var(--text-secondary, #333);
|
||||
color: var(--bs-secondary-color, #333);
|
||||
}
|
||||
|
||||
.o_fp_finish_block_list {
|
||||
@@ -831,9 +834,9 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_action_note {
|
||||
color: var(--text-secondary, #555);
|
||||
color: var(--bs-secondary-color, #555);
|
||||
font-style: italic;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: #f3f4f6;
|
||||
background: var(--bs-tertiary-bg, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -173,3 +173,89 @@ $fp-md-page: var(--fp-page-bg, #{$_fp_md_page_hex});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Partial-order handling — easy-advance layout
|
||||
// "Send Parts Forward" dialog: destination banner + big-tap qty stepper
|
||||
// (no keyboard) + collapsed advanced fields. Reuses the $fp-md-* tokens so
|
||||
// dark mode is handled at compile time.
|
||||
.o_fp_move_dialog {
|
||||
.o_fp_move_route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: .6rem .75rem;
|
||||
background: $fp-md-page;
|
||||
border: 1px solid $fp-md-border;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
|
||||
.route-from { color: $fp-md-muted; }
|
||||
.route-arrow { color: $fp-md-accent; font-weight: 800; }
|
||||
.route-to { color: $fp-md-accent; }
|
||||
}
|
||||
|
||||
.o_fp_move_qty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
|
||||
label { font-weight: 600; margin: 0; }
|
||||
}
|
||||
|
||||
.o_fp_qty_stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
|
||||
.qty-btn {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
border: 1px solid $fp-md-border;
|
||||
border-radius: 8px;
|
||||
background: $fp-md-card;
|
||||
color: $fp-md-accent;
|
||||
|
||||
&:disabled { opacity: .4; }
|
||||
}
|
||||
.qty-value {
|
||||
min-width: 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.qty-all {
|
||||
margin-left: .5rem;
|
||||
padding: .5rem .9rem;
|
||||
border: 1px solid $fp-md-border;
|
||||
border-radius: 8px;
|
||||
background: $fp-md-card;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qty_hint {
|
||||
color: $fp-md-muted;
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.o_fp_move_advanced_toggle {
|
||||
text-align: center;
|
||||
|
||||
.btn-link { color: $fp-md-muted; text-decoration: none; }
|
||||
}
|
||||
|
||||
.o_fp_move_advanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
padding: .6rem .75rem;
|
||||
border: 1px dashed $fp-md-border;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
}
|
||||
}
|
||||
.toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -81,8 +83,10 @@
|
||||
cursor: pointer;
|
||||
color: $plant-text;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
i { font-size: 15px; line-height: 1; }
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||
@@ -93,6 +97,24 @@
|
||||
color: #5e4400;
|
||||
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,
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
<!-- 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 -->
|
||||
<div class="card-chips">
|
||||
<span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>
|
||||
|
||||
@@ -2,75 +2,48 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.FpMovePartsDialog">
|
||||
<Dialog title.translate="Move Parts" size="'lg'">
|
||||
<Dialog title.translate="Send Parts Forward" size="'md'">
|
||||
<div class="o_fp_move_dialog" t-if="!state.loading">
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>Part Count</label>
|
||||
<input type="number" t-model.number="state.qty"
|
||||
t-att-min="1" t-att-max="state.qtyAvailable"/>
|
||||
<span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span>
|
||||
<!-- Destination banner — operator sees exactly where parts go,
|
||||
nothing to guess. -->
|
||||
<div class="o_fp_move_route">
|
||||
<span class="route-from" t-esc="state.fromStep.name"/>
|
||||
<span class="route-arrow"> → </span>
|
||||
<span class="route-to" t-esc="state.toStep.name"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>From Node</label>
|
||||
<span t-esc="state.fromStep.name"/>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field" t-if="state.fromStep.tank_name">
|
||||
<label>From Station</label>
|
||||
<span t-esc="state.fromStep.tank_name"/>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<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/>
|
||||
<!-- Qty stepper — no keyboard. Defaults to all parked here. -->
|
||||
<div class="o_fp_move_qty">
|
||||
<label>How many to send?</label>
|
||||
<div class="o_fp_qty_stepper">
|
||||
<button class="qty-btn" t-on-click="decQty"
|
||||
t-att-disabled="state.qty <= 1">−</button>
|
||||
<span class="qty-value" t-esc="state.qty"/>
|
||||
<button class="qty-btn" t-on-click="incQty"
|
||||
t-att-disabled="state.qty >= state.qtyAvailable">+</button>
|
||||
<button class="qty-all" t-on-click="setQtyAll">
|
||||
All (<t t-esc="state.qtyAvailable"/>)
|
||||
</button>
|
||||
</div>
|
||||
<span class="o_fp_qty_hint"><t t-esc="state.qtyAvailable"/> parked here</span>
|
||||
</div>
|
||||
|
||||
<!-- To Station (tank) — only when the recipe offers a choice -->
|
||||
<div class="o_fp_move_field"
|
||||
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
|
||||
<label>To Station</label>
|
||||
<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>
|
||||
</t>
|
||||
</select>
|
||||
<span/>
|
||||
</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>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_compliance_prompts"
|
||||
t-if="state.transitionPrompts.length">
|
||||
<h5>Compliance Prompts</h5>
|
||||
<!-- Compliance prompts — only when the recipe author required
|
||||
them. Pickers/checkboxes, minimal free text. -->
|
||||
<div class="o_fp_compliance_prompts" t-if="state.transitionPrompts.length">
|
||||
<h5>Required before sending</h5>
|
||||
<t t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
|
||||
<div class="o_fp_move_field">
|
||||
<label>
|
||||
@@ -94,13 +67,12 @@
|
||||
</t>
|
||||
</select>
|
||||
<span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
|
||||
<span t-else=""/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Blockers — inline resolve where possible -->
|
||||
<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">
|
||||
<div class="o_fp_blocker_row"
|
||||
t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
|
||||
@@ -114,6 +86,39 @@
|
||||
</div>
|
||||
</t>
|
||||
</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 t-if="state.loading">Loading…</div>
|
||||
@@ -126,7 +131,7 @@
|
||||
t-att-disabled="!canCommit"
|
||||
t-att-title="blockerTooltip"
|
||||
t-on-click="onCommit">
|
||||
MOVE (<t t-esc="state.qty"/>)
|
||||
SEND (<t t-esc="state.qty"/>)
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
|
||||
@@ -23,13 +23,18 @@
|
||||
<button t-att-class="modeClass('manager')"
|
||||
t-on-click="() => this.setMode('manager')">Manager</button>
|
||||
</div>
|
||||
<!-- Text/wedge scan drawer toggle. Camera path
|
||||
is the QrScanner inline below — it
|
||||
opens its own modal + decoder. -->
|
||||
<button class="toolbar-btn"
|
||||
t-att-class="state.showScan ? 'toolbar-btn active' : 'toolbar-btn'"
|
||||
t-on-click="toggleScan">⌨️ Scan Code</button>
|
||||
<QrScanner cssClass="'toolbar-btn'" label="'📷 Camera'"/>
|
||||
<!-- "Scan QR" = the QrScanner camera path (the
|
||||
primary way to scan a printed job sticker).
|
||||
The component renders its own fa-qrcode
|
||||
icon, so the label must be plain text — an
|
||||
emoji here would double up the icon.
|
||||
"Enter Code" = the manual / hardware-scanner-
|
||||
gun text drawer (a wedge gun types the code;
|
||||
no camera). -->
|
||||
<QrScanner cssClass="'toolbar-btn'" label="'Scan QR'"/>
|
||||
<button class="toolbar-btn scan-alt"
|
||||
t-att-class="state.showScan ? 'active' : ''"
|
||||
t-on-click="toggleScan"><i class="fa fa-keyboard-o me-1"/>Enter Code</button>
|
||||
<button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,14 +64,12 @@ This module provides external portal access for:
|
||||
'data/portal_menu_data.xml',
|
||||
'data/ir_actions_server_data.xml',
|
||||
'data/welcome_articles.xml',
|
||||
'data/visit_data.xml',
|
||||
# Views
|
||||
'views/res_partner_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/assessment_views.xml',
|
||||
'views/loaner_checkout_views.xml',
|
||||
'views/pdf_template_views.xml',
|
||||
'views/visit_views.xml',
|
||||
# Portal Templates
|
||||
'views/portal_templates.xml',
|
||||
'views/portal_assessment_express.xml',
|
||||
@@ -81,7 +79,6 @@ This module provides external portal access for:
|
||||
'views/portal_technician_templates.xml',
|
||||
'views/portal_book_assessment.xml',
|
||||
'views/portal_page11_sign_templates.xml',
|
||||
'views/portal_visit.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
|
||||
@@ -815,15 +815,7 @@ class AssessmentPortal(CustomerPortal):
|
||||
vals['wheelchair_type'] = kw.get('wheelchair_type')
|
||||
if kw.get('powerchair_type'):
|
||||
vals['powerchair_type'] = kw.get('powerchair_type')
|
||||
if kw.get('scooter_type'):
|
||||
vals['scooter_type'] = kw.get('scooter_type')
|
||||
if kw.get('scooter_max_range'):
|
||||
vals['scooter_max_range'] = float(kw.get('scooter_max_range') or 0)
|
||||
if kw.get('x_fc_power_home_accessible'):
|
||||
vals['x_fc_power_home_accessible'] = kw.get('x_fc_power_home_accessible')
|
||||
if kw.get('x_fc_power_home_access_notes'):
|
||||
vals['x_fc_power_home_access_notes'] = kw.get('x_fc_power_home_access_notes')
|
||||
|
||||
|
||||
# Float measurements
|
||||
float_fields = [
|
||||
'rollator_handle_height', 'rollator_seat_height',
|
||||
|
||||
@@ -2479,56 +2479,6 @@ class AuthorizerPortal(CustomerPortal):
|
||||
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
|
||||
return request.render(template, values)
|
||||
|
||||
# ==========================================================================
|
||||
# ASSESSMENT VISIT WORKSPACE (Phase 1b/3)
|
||||
# ==========================================================================
|
||||
@http.route('/my/visit/new', type='http', auth='user', website=True)
|
||||
def visit_new(self, **kw):
|
||||
"""Start a new assessment visit and open its workspace."""
|
||||
partner = request.env.user.partner_id
|
||||
if not partner.is_sales_rep_portal and not partner.is_authorizer:
|
||||
return request.redirect('/my')
|
||||
visit = request.env['fusion.assessment.visit'].sudo().create({
|
||||
'sales_rep_id': request.env.user.id,
|
||||
})
|
||||
return request.redirect('/my/visit/%s' % visit.id)
|
||||
|
||||
@http.route('/my/visit/<int:visit_id>', type='http', auth='user', website=True)
|
||||
def visit_workspace(self, visit_id, **kw):
|
||||
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
|
||||
if not visit.exists():
|
||||
return request.redirect('/my')
|
||||
return request.render('fusion_portal.portal_visit_workspace', {
|
||||
'visit': visit,
|
||||
'page_name': 'visit',
|
||||
'error': kw.get('error'),
|
||||
})
|
||||
|
||||
@http.route('/my/visit/<int:visit_id>/save', type='http', auth='user', methods=['POST'], website=True, csrf=True)
|
||||
def visit_save_client(self, visit_id, **post):
|
||||
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
|
||||
if visit.exists():
|
||||
visit.write({
|
||||
'client_name': (post.get('client_name') or '').strip(),
|
||||
'client_phone': (post.get('client_phone') or '').strip(),
|
||||
'client_email': (post.get('client_email') or '').strip(),
|
||||
'client_address': (post.get('client_address') or '').strip(),
|
||||
'x_fc_income_under_mod_threshold': post.get('x_fc_income_under_mod_threshold') or 'unknown',
|
||||
})
|
||||
return request.redirect('/my/visit/%s' % visit_id)
|
||||
|
||||
@http.route('/my/visit/<int:visit_id>/complete', type='http', auth='user', methods=['POST'], website=True, csrf=True)
|
||||
def visit_complete(self, visit_id, **post):
|
||||
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
|
||||
if not visit.exists():
|
||||
return request.redirect('/my')
|
||||
try:
|
||||
visit.action_complete_visit()
|
||||
except Exception as e:
|
||||
_logger.warning("Visit %s completion failed: %s", visit_id, e)
|
||||
return request.redirect('/my/visit/%s?error=%s' % (visit_id, str(e)))
|
||||
return request.redirect('/my/visit/%s' % visit_id)
|
||||
|
||||
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
|
||||
def accessibility_assessment_save(self, **post):
|
||||
"""Save an accessibility assessment and optionally create a Sale Order"""
|
||||
@@ -2597,13 +2547,6 @@ class AuthorizerPortal(CustomerPortal):
|
||||
if partner.is_authorizer:
|
||||
vals['authorizer_id'] = partner.id
|
||||
|
||||
# Link to a visit if this form was launched from the workspace.
|
||||
if post.get('visit_id'):
|
||||
try:
|
||||
vals['visit_id'] = int(post.get('visit_id'))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Create the assessment
|
||||
assessment = Assessment.create(vals)
|
||||
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
|
||||
@@ -2629,12 +2572,8 @@ class AuthorizerPortal(CustomerPortal):
|
||||
if video_data:
|
||||
self._attach_accessibility_video(assessment, video_data, video_filename)
|
||||
|
||||
# Complete the assessment and create Sale Order if requested.
|
||||
# When launched from a visit, always save as a draft linked to the
|
||||
# visit — the VISIT completion creates the grouped sale order(s).
|
||||
# Complete the assessment and create Sale Order if requested
|
||||
create_sale_order = post.get('create_sale_order', True)
|
||||
if vals.get('visit_id'):
|
||||
create_sale_order = False
|
||||
if create_sale_order:
|
||||
sale_order = assessment.action_complete()
|
||||
return {
|
||||
@@ -2647,13 +2586,12 @@ class AuthorizerPortal(CustomerPortal):
|
||||
'redirect_url': f'/my/sales/case/{sale_order.id}',
|
||||
}
|
||||
else:
|
||||
redirect_url = ('/my/visit/%s' % vals['visit_id']) if vals.get('visit_id') else '/my/accessibility/list'
|
||||
return {
|
||||
'success': True,
|
||||
'assessment_id': assessment.id,
|
||||
'assessment_ref': assessment.reference,
|
||||
'message': f'Assessment {assessment.reference} saved.',
|
||||
'redirect_url': redirect_url,
|
||||
'message': f'Assessment {assessment.reference} saved as draft.',
|
||||
'redirect_url': '/my/accessibility/list',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Reference sequence for assessment visits (VISIT/2026/0001) -->
|
||||
<record id="seq_fusion_assessment_visit" model="ir.sequence">
|
||||
<field name="name">Assessment Visit</field>
|
||||
<field name="code">fusion.assessment.visit</field>
|
||||
<field name="prefix">VISIT/%(year)s/</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -7,6 +7,5 @@ from . import adp_document
|
||||
from . import assessment
|
||||
from . import accessibility_assessment
|
||||
from . import sale_order
|
||||
from . import visit
|
||||
from . import loaner_checkout
|
||||
from . import pdf_template
|
||||
@@ -157,14 +157,7 @@ class FusionAccessibilityAssessment(models.Model):
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
visit_id = fields.Many2one(
|
||||
'fusion.assessment.visit',
|
||||
string='Assessment Visit',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='The home visit this accessibility assessment belongs to.',
|
||||
)
|
||||
|
||||
|
||||
# Dates
|
||||
assessment_date = fields.Date(
|
||||
string='Assessment Date',
|
||||
|
||||
@@ -45,7 +45,6 @@ class FusionAssessment(models.Model):
|
||||
('rollator', 'Rollator'),
|
||||
('wheelchair', 'Wheelchair'),
|
||||
('powerchair', 'Powerchair'),
|
||||
('scooter', 'Mobility Scooter'),
|
||||
], string='Equipment Type', tracking=True, index=True)
|
||||
|
||||
# Rollator Types
|
||||
@@ -70,31 +69,6 @@ class FusionAssessment(models.Model):
|
||||
('type_2', 'Adult Power Base Type 2'),
|
||||
('type_3', 'Adult Power Base Type 3'),
|
||||
], string='Powerchair Type')
|
||||
|
||||
# ===== MOBILITY SCOOTER (ADP) — 2026-06 Phase 2 =====
|
||||
scooter_type = fields.Selection([
|
||||
('travel_3', '3-Wheel Travel/Portable'),
|
||||
('travel_4', '4-Wheel Travel/Portable'),
|
||||
('standard_3', '3-Wheel Standard'),
|
||||
('standard_4', '4-Wheel Standard'),
|
||||
('heavy_duty', 'Heavy-Duty / Bariatric'),
|
||||
], string='Scooter Type')
|
||||
scooter_max_range = fields.Float(
|
||||
string='Maximum Range Needed (km)', digits=(10, 1),
|
||||
help='Maximum distance the client needs the scooter to travel on a charge.',
|
||||
)
|
||||
|
||||
# ===== POWER-MOBILITY HOME ACCESSIBILITY (ADP hard rule) =====
|
||||
# Applies to scooter + power wheelchair: ADP funds power mobility only if the
|
||||
# device can enter and be used at the residence independently, without lifting.
|
||||
x_fc_power_home_accessible = fields.Selection([
|
||||
('yes', 'Yes — usable inside and outside independently'),
|
||||
('no', 'No — home needs accessibility work'),
|
||||
], string='Home accessible for power-mobility device?',
|
||||
help='ADP will not fund a scooter / power wheelchair if the home cannot '
|
||||
'take it (device left outside or in the garage). If No, the home '
|
||||
'needs an accessibility product (ramp / porch lift).')
|
||||
x_fc_power_home_access_notes = fields.Text(string='Home Access Notes')
|
||||
|
||||
# ===== EXPRESS FORM: ROLLATOR MEASUREMENTS =====
|
||||
rollator_handle_height = fields.Float(string='Handle Height (inches)', digits=(10, 2))
|
||||
@@ -451,15 +425,7 @@ class FusionAssessment(models.Model):
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
visit_id = fields.Many2one(
|
||||
'fusion.assessment.visit',
|
||||
string='Assessment Visit',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='The home visit this ADP assessment belongs to (groups multiple '
|
||||
'assessments / funding workflows from one visit).',
|
||||
)
|
||||
|
||||
|
||||
# ===== COMPUTED FIELDS =====
|
||||
document_count = fields.Integer(
|
||||
string='Document Count',
|
||||
|
||||
@@ -53,17 +53,6 @@ class SaleOrder(models.Model):
|
||||
'sale order — stair lift, VPL, ceiling lift, ramp, bathroom mod, '
|
||||
'or tub cutout visits.',
|
||||
)
|
||||
|
||||
# Link to the assessment visit (one visit -> one SO per funding workflow).
|
||||
visit_id = fields.Many2one(
|
||||
'fusion.assessment.visit',
|
||||
string='Assessment Visit',
|
||||
readonly=True,
|
||||
index=True,
|
||||
help='The home visit this sale order was generated from. A visit '
|
||||
'produces one sale order per funding workflow (ADP / MOD / ODSP / '
|
||||
'private / ...).',
|
||||
)
|
||||
|
||||
# Authorizer helper field (consolidates multiple possible fields)
|
||||
portal_authorizer_id = fields.Many2one(
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Assessment Visit — bundles the assessments done during one home visit.
|
||||
|
||||
A sales rep + occupational therapist visit a client for 30-45 min and may do
|
||||
several assessments (an ADP wheelchair plus accessibility products like a stair
|
||||
lift, ramp, or tub cutout). The Visit is the hub that holds the client/context
|
||||
ONCE and, on completion, groups its assessments by FUNDING WORKFLOW
|
||||
(x_fc_sale_type) and creates ONE draft sale order per workflow — never one
|
||||
combined SO, and never a separate SO per item within the same funding.
|
||||
|
||||
See docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md.
|
||||
"""
|
||||
import logging
|
||||
from markupsafe import Markup
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Accessibility funding source -> sale.order x_fc_sale_type. Mirrors
|
||||
# fusion.accessibility.assessment._create_draft_sale_order so a grouped Visit
|
||||
# routes exactly the way a single accessibility assessment would.
|
||||
ACCESSIBILITY_SALE_TYPE_MAP = {
|
||||
'march_of_dimes': 'march_of_dimes',
|
||||
'odsp': 'odsp',
|
||||
'wsib': 'wsib',
|
||||
'hardship': 'hardship',
|
||||
'insurance': 'insurance',
|
||||
'direct_private': 'direct_private',
|
||||
'other': 'other',
|
||||
}
|
||||
|
||||
|
||||
class FusionAssessmentVisit(models.Model):
|
||||
_name = 'fusion.assessment.visit'
|
||||
_description = 'Assessment Visit'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'visit_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Visit Reference', required=True, readonly=True,
|
||||
copy=False, default=lambda self: _('New'),
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('measuring', 'Measuring'),
|
||||
('client_pending', 'Client Details Pending'),
|
||||
('done', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
default='measuring', tracking=True, copy=False,
|
||||
)
|
||||
|
||||
# --- Shared client + context (entered once for the whole visit) ---------
|
||||
partner_id = fields.Many2one('res.partner', string='Client', tracking=True)
|
||||
client_name = fields.Char(string='Client Name', tracking=True)
|
||||
client_phone = fields.Char(string='Phone')
|
||||
client_email = fields.Char(string='Email')
|
||||
client_address = fields.Char(string='Address')
|
||||
visit_date = fields.Date(
|
||||
string='Visit Date', default=fields.Date.context_today, tracking=True,
|
||||
)
|
||||
sales_rep_id = fields.Many2one(
|
||||
'res.users', string='Sales Rep',
|
||||
default=lambda self: self.env.user, tracking=True,
|
||||
)
|
||||
authorizer_id = fields.Many2one(
|
||||
'res.partner', string='Occupational Therapist', tracking=True,
|
||||
)
|
||||
|
||||
# --- March of Dimes funding context (informational; spec §4.1) ----------
|
||||
# MOD covers up to $15,000 per person (lifetime), income-gated.
|
||||
x_fc_income_under_mod_threshold = fields.Selection(
|
||||
selection=[
|
||||
('yes', 'Yes — under threshold (full $15k available)'),
|
||||
('no', 'No — over threshold (may be denied / partial)'),
|
||||
('unknown', 'Unknown'),
|
||||
],
|
||||
string='Income under MOD threshold?', default='unknown',
|
||||
help='March of Dimes funds up to $15,000 per person (lifetime) when the '
|
||||
"client's income is under that year's threshold; over it, MOD may "
|
||||
'deny or partially approve. Reminder only — no automatic cap '
|
||||
'enforcement in this version.',
|
||||
)
|
||||
|
||||
# --- Assessments performed during this visit ----------------------------
|
||||
adp_assessment_ids = fields.One2many(
|
||||
'fusion.assessment', 'visit_id', string='ADP Assessments',
|
||||
)
|
||||
accessibility_assessment_ids = fields.One2many(
|
||||
'fusion.accessibility.assessment', 'visit_id',
|
||||
string='Accessibility Assessments',
|
||||
)
|
||||
|
||||
# --- Sale orders produced — one per funding workflow --------------------
|
||||
sale_order_ids = fields.One2many(
|
||||
'sale.order', 'visit_id', string='Sale Orders',
|
||||
)
|
||||
|
||||
assessment_count = fields.Integer(compute='_compute_counts')
|
||||
sale_order_count = fields.Integer(compute='_compute_counts')
|
||||
has_mod_items = fields.Boolean(compute='_compute_has_mod_items')
|
||||
|
||||
@api.depends('adp_assessment_ids', 'accessibility_assessment_ids', 'sale_order_ids')
|
||||
def _compute_counts(self):
|
||||
for visit in self:
|
||||
visit.assessment_count = (
|
||||
len(visit.adp_assessment_ids)
|
||||
+ len(visit.accessibility_assessment_ids)
|
||||
)
|
||||
visit.sale_order_count = len(visit.sale_order_ids)
|
||||
|
||||
@api.depends('accessibility_assessment_ids.x_fc_funding_source')
|
||||
def _compute_has_mod_items(self):
|
||||
for visit in self:
|
||||
visit.has_mod_items = any(
|
||||
a.x_fc_funding_source == 'march_of_dimes'
|
||||
for a in visit.accessibility_assessment_ids
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals['name'] == _('New'):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.assessment.visit')
|
||||
vals['name'] = seq or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Completion — group assessments by funding workflow → one SO each
|
||||
# ------------------------------------------------------------------
|
||||
def _ensure_partner(self):
|
||||
"""Resolve the client partner for the visit, reusing an assessment's
|
||||
partner/_ensure_partner when one is already set."""
|
||||
self.ensure_one()
|
||||
if self.partner_id:
|
||||
return self.partner_id
|
||||
# Borrow a child assessment's partner resolution if available.
|
||||
for assessment in self.accessibility_assessment_ids:
|
||||
if assessment.partner_id:
|
||||
return assessment.partner_id
|
||||
if hasattr(assessment, '_ensure_partner'):
|
||||
return assessment._ensure_partner()
|
||||
for assessment in self.adp_assessment_ids:
|
||||
if assessment.partner_id:
|
||||
return assessment.partner_id
|
||||
if self.client_name:
|
||||
return self.env['res.partner'].sudo().create({
|
||||
'name': self.client_name,
|
||||
'phone': self.client_phone or False,
|
||||
'email': self.client_email or False,
|
||||
})
|
||||
raise UserError(_('Set a client (or client name) on the visit first.'))
|
||||
|
||||
def _create_grouped_sale_order(self, partner, sale_type, accessibility_assessments):
|
||||
"""Create ONE draft sale order for a set of same-funding accessibility
|
||||
assessments, link them all to it, and post each one's spec to chatter.
|
||||
Mirrors fusion.accessibility.assessment._create_draft_sale_order but for
|
||||
a group sharing one funding workflow."""
|
||||
self.ensure_one()
|
||||
SaleOrder = self.env['sale.order'].sudo()
|
||||
|
||||
so_vals = {
|
||||
'partner_id': partner.id,
|
||||
'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id,
|
||||
'state': 'draft',
|
||||
'origin': _('Visit %s (%s)') % (self.name, sale_type),
|
||||
'x_fc_sale_type': sale_type,
|
||||
'visit_id': self.id,
|
||||
}
|
||||
if self.authorizer_id:
|
||||
so_vals['x_fc_authorizer_id'] = self.authorizer_id.id
|
||||
# MOD: pre-fill the accessibility specialist from the sales rep.
|
||||
if sale_type == 'march_of_dimes' and self.sales_rep_id and self.sales_rep_id.partner_id:
|
||||
so_vals['x_fc_mod_accessibility_specialist_id'] = self.sales_rep_id.partner_id.id
|
||||
|
||||
sale_order = SaleOrder.create(so_vals)
|
||||
for assessment in accessibility_assessments:
|
||||
assessment.sale_order_id = sale_order.id
|
||||
assessment._add_assessment_tag(sale_order)
|
||||
sale_order.message_post(
|
||||
body=Markup(assessment._format_assessment_html_table()),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
assessment.write({'state': 'completed'})
|
||||
_logger.info(
|
||||
"Visit %s created %s sale order %s grouping %d accessibility assessment(s)",
|
||||
self.name, sale_type, sale_order.name, len(accessibility_assessments),
|
||||
)
|
||||
return sale_order
|
||||
|
||||
def _validate_adp_combination(self, adp_assessments):
|
||||
"""Enforce ADP device-combination rules: at most one seated-mobility
|
||||
device (manual wheelchair / power wheelchair / scooter), optionally one
|
||||
walker/rollator, no duplicates."""
|
||||
seated_types = {'wheelchair', 'powerchair', 'scooter'}
|
||||
seated = [a for a in adp_assessments if a.equipment_type in seated_types]
|
||||
walkers = [a for a in adp_assessments if a.equipment_type == 'rollator']
|
||||
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
|
||||
if len(seated) > 1:
|
||||
raise UserError(_(
|
||||
'An ADP order can include only one seated-mobility device '
|
||||
'(manual wheelchair, power wheelchair, or scooter). This visit has: %s.'
|
||||
) % ', '.join(labels.get(a.equipment_type, a.equipment_type) for a in seated))
|
||||
if len(walkers) > 1:
|
||||
raise UserError(_('An ADP order can include only one walker / rollator.'))
|
||||
|
||||
def action_complete_visit(self):
|
||||
"""Group the visit's accessibility assessments by funding workflow and
|
||||
create one draft SO per workflow. ADP equipment assessments keep their
|
||||
existing one-assessment-one-SO completion for now (ADP multi-device
|
||||
grouping arrives in Phase 2)."""
|
||||
self.ensure_one()
|
||||
if self.state == 'done':
|
||||
raise UserError(_('This visit is already completed.'))
|
||||
if not (self.accessibility_assessment_ids or self.adp_assessment_ids):
|
||||
raise UserError(_('Add at least one assessment before completing the visit.'))
|
||||
|
||||
partner = self._ensure_partner()
|
||||
|
||||
# Group accessibility assessments by their funding -> sale type.
|
||||
by_sale_type = {}
|
||||
for assessment in self.accessibility_assessment_ids:
|
||||
if assessment.sale_order_id:
|
||||
continue # already has an SO; don't duplicate
|
||||
sale_type = ACCESSIBILITY_SALE_TYPE_MAP.get(
|
||||
assessment.x_fc_funding_source, 'direct_private')
|
||||
by_sale_type.setdefault(sale_type, []).append(assessment)
|
||||
|
||||
for sale_type, group in by_sale_type.items():
|
||||
self._create_grouped_sale_order(partner, sale_type, group)
|
||||
|
||||
# ADP equipment assessments: one ADP order per funding type, with the
|
||||
# device-combination guard, reusing the existing (prod-tested) express
|
||||
# completion. The first device creates the SO; the rest attach to it.
|
||||
adp_by_type = {}
|
||||
for assessment in self.adp_assessment_ids:
|
||||
if assessment.sale_order_id:
|
||||
continue
|
||||
adp_by_type.setdefault(self._assessment_sale_type(assessment), []).append(assessment)
|
||||
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
|
||||
for sale_type, group in adp_by_type.items():
|
||||
self._validate_adp_combination(group)
|
||||
# Make sure each device carries the visit's client + OT so the
|
||||
# existing completion logic has what it needs.
|
||||
for assessment in group:
|
||||
vals = {}
|
||||
if not assessment.client_name:
|
||||
vals['client_name'] = self.client_name or partner.name
|
||||
if not assessment.authorizer_id and self.authorizer_id:
|
||||
vals['authorizer_id'] = self.authorizer_id.id
|
||||
if not assessment.partner_id:
|
||||
vals['partner_id'] = partner.id
|
||||
if vals:
|
||||
assessment.write(vals)
|
||||
primary = group[0]
|
||||
sale_order = primary.action_complete_express()
|
||||
sale_order.write({'visit_id': self.id, 'x_fc_sale_type': sale_type})
|
||||
for extra in group[1:]:
|
||||
extra.write({'state': 'completed', 'sale_order_id': sale_order.id})
|
||||
sale_order.message_post(
|
||||
body=Markup('<p><strong>Additional ADP device on this order:</strong> %s</p>')
|
||||
% labels.get(extra.equipment_type, extra.equipment_type or 'device'),
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
self.write({'state': 'done', 'partner_id': partner.id})
|
||||
return self._action_view_sale_orders()
|
||||
|
||||
def _action_view_sale_orders(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Visit Sale Orders'),
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('visit_id', '=', self.id)],
|
||||
'context': {'create': False},
|
||||
}
|
||||
@@ -7,8 +7,6 @@ access_fusion_assessment_user,fusion.assessment.user,model_fusion_assessment,bas
|
||||
access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0
|
||||
access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1
|
||||
access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0
|
||||
access_fusion_assessment_visit_user,fusion.assessment.visit.user,model_fusion_assessment_visit,base.group_user,1,1,1,1
|
||||
access_fusion_assessment_visit_portal,fusion.assessment.visit.portal,model_fusion_assessment_visit,base.group_portal,1,1,1,0
|
||||
access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1
|
||||
access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1
|
||||
access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1
|
||||
|
@@ -388,7 +388,6 @@
|
||||
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="visit_id" id="acc_visit_id"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -648,15 +647,6 @@
|
||||
// Fallback if Google Maps not loaded
|
||||
window.initAddressAutocomplete = window.initAddressAutocomplete || function() {};
|
||||
|
||||
// Carry visit_id from the workspace launch (?visit_id=) into the form
|
||||
(function() {
|
||||
var _vid = new URLSearchParams(window.location.search).get('visit_id');
|
||||
if (_vid) {
|
||||
var f = document.getElementById('acc_visit_id');
|
||||
if (f) { f.value = _vid; }
|
||||
}
|
||||
})();
|
||||
|
||||
// Form submission
|
||||
function saveAssessment(createSaleOrder) {
|
||||
var form = document.getElementById('accessibility_form');
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
<option value="rollator" t-att-selected="assessment.equipment_type == 'rollator' if assessment else False">Rollator</option>
|
||||
<option value="wheelchair" t-att-selected="assessment.equipment_type == 'wheelchair' if assessment else False">Wheelchair</option>
|
||||
<option value="powerchair" t-att-selected="assessment.equipment_type == 'powerchair' if assessment else False">Powerchair</option>
|
||||
<option value="scooter" t-att-selected="assessment.equipment_type == 'scooter' if assessment else False">Mobility Scooter</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -689,62 +688,8 @@
|
||||
<label class="form-label fw-bold">Additional Information/Customization</label>
|
||||
<textarea name="additional_customization" class="form-control powerchair-field" rows="4" placeholder="Enter any additional requirements or customization notes..."><t t-esc="assessment.additional_customization if assessment else ''"/></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Power-mobility home-accessibility — ADP hard rule -->
|
||||
<div class="mb-4 p-3 border rounded bg-light">
|
||||
<label class="form-label fw-bold">Home accessible for the device — inside & outside?</label>
|
||||
<select name="x_fc_power_home_accessible" class="form-control">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
|
||||
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
|
||||
</select>
|
||||
<div class="alert alert-warning mt-2 mb-0">
|
||||
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
|
||||
</div>
|
||||
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MOBILITY SCOOTER ===== -->
|
||||
<div id="scooter_form" class="equipment-form" style="display: none;">
|
||||
<h2 class="text-center fw-bold text-uppercase mb-4">Mobility Scooter Assessment</h2>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">Scooter Type</label>
|
||||
<select name="scooter_type" class="form-select">
|
||||
<option value="">-- Select Type --</option>
|
||||
<option value="travel_3" t-att-selected="assessment.scooter_type == 'travel_3' if assessment else False">3-Wheel Travel/Portable</option>
|
||||
<option value="travel_4" t-att-selected="assessment.scooter_type == 'travel_4' if assessment else False">4-Wheel Travel/Portable</option>
|
||||
<option value="standard_3" t-att-selected="assessment.scooter_type == 'standard_3' if assessment else False">3-Wheel Standard</option>
|
||||
<option value="standard_4" t-att-selected="assessment.scooter_type == 'standard_4' if assessment else False">4-Wheel Standard</option>
|
||||
<option value="heavy_duty" t-att-selected="assessment.scooter_type == 'heavy_duty' if assessment else False">Heavy-Duty / Bariatric</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<label class="form-label fw-bold">Maximum Range Needed (km)</label>
|
||||
<div class="input-group">
|
||||
<input type="number" step="1" name="scooter_max_range" class="form-control"
|
||||
t-att-value="assessment.scooter_max_range if assessment else ''"/>
|
||||
<span class="input-group-text">km</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Power-mobility home-accessibility — ADP hard rule -->
|
||||
<div class="mb-4 p-3 border rounded bg-light">
|
||||
<label class="form-label fw-bold">Home accessible for the device — inside & outside?</label>
|
||||
<select name="x_fc_power_home_accessible" class="form-control">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
|
||||
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
|
||||
</select>
|
||||
<div class="alert alert-warning mt-2 mb-0">
|
||||
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
|
||||
</div>
|
||||
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1333,7 +1278,6 @@
|
||||
var rollatorForm = document.getElementById('rollator_form');
|
||||
var wheelchairForm = document.getElementById('wheelchair_form');
|
||||
var powerchairForm = document.getElementById('powerchair_form');
|
||||
var scooterForm = document.getElementById('scooter_form');
|
||||
var wheelchairTypeSelect = document.querySelector('select[name="wheelchair_type"]');
|
||||
var reasonSelect = document.getElementById('reason_for_application');
|
||||
var previousFundingContainer = document.getElementById('previous_funding_date_container');
|
||||
@@ -1395,16 +1339,13 @@
|
||||
disableFormInputs(rollatorForm);
|
||||
disableFormInputs(wheelchairForm);
|
||||
disableFormInputs(powerchairForm);
|
||||
disableFormInputs(scooterForm);
|
||||
|
||||
|
||||
if (value === 'rollator') {
|
||||
enableFormInputs(rollatorForm);
|
||||
} else if (value === 'wheelchair') {
|
||||
enableFormInputs(wheelchairForm);
|
||||
} else if (value === 'powerchair') {
|
||||
enableFormInputs(powerchairForm);
|
||||
} else if (value === 'scooter') {
|
||||
enableFormInputs(scooterForm);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<template id="portal_visit_workspace" name="Assessment Visit Workspace">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<div class="container py-4">
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/my">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Assessment Visit</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger"><i class="fa fa-exclamation-circle"/> <t t-esc="error"/></div>
|
||||
</t>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="mb-0"><i class="fa fa-clipboard text-primary"/> Visit <t t-esc="visit.name"/></h3>
|
||||
<span class="badge bg-secondary"><span t-field="visit.state"/></span>
|
||||
</div>
|
||||
|
||||
<t t-if="visit.state == 'done'">
|
||||
<div class="alert alert-success">
|
||||
<strong>Visit completed.</strong> Sale orders created:
|
||||
<ul class="mb-0">
|
||||
<t t-foreach="visit.sale_order_ids" t-as="so">
|
||||
<li><a t-attf-href="/my/sales/case/{{so.id}}"><t t-esc="so.name"/></a> — <span t-field="so.x_fc_sale_type"/></li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Assessments added this visit -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white"><strong>Assessments this visit</strong> (<t t-esc="visit.assessment_count"/>)</div>
|
||||
<div class="card-body">
|
||||
<p t-if="not visit.assessment_count" class="text-muted mb-0">
|
||||
Nothing added yet — use the buttons below to add what you're assessing.
|
||||
</p>
|
||||
<ul class="list-group" t-if="visit.assessment_count">
|
||||
<t t-foreach="visit.adp_assessment_ids" t-as="a">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa fa-wheelchair text-primary"/> ADP — <span t-field="a.equipment_type"/></span>
|
||||
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
|
||||
</li>
|
||||
</t>
|
||||
<t t-foreach="visit.accessibility_assessment_ids" t-as="a">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span><span t-field="a.assessment_type"/> — <span t-field="a.x_fc_funding_source"/></span>
|
||||
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="visit.state != 'done'">
|
||||
<!-- Add assessment -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white"><strong>+ Add assessment</strong></div>
|
||||
<div class="card-body d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-outline-primary" t-attf-href="/my/assessment/express?visit_id={{visit.id}}">Wheelchair / ADP</a>
|
||||
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/straight?visit_id={{visit.id}}">Straight Stair Lift</a>
|
||||
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/curved?visit_id={{visit.id}}">Curved Stair Lift</a>
|
||||
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/vpl?visit_id={{visit.id}}">Platform / Porch Lift</a>
|
||||
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ceiling-lift?visit_id={{visit.id}}">Ceiling Lift</a>
|
||||
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ramp?visit_id={{visit.id}}">Custom Ramp</a>
|
||||
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/bathroom?visit_id={{visit.id}}">Bathroom Mod</a>
|
||||
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/tub-cutout?visit_id={{visit.id}}">Tub Cutout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client details (deferred) -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white"><strong>Client details</strong>
|
||||
<span class="text-muted small">— fill in after the therapist leaves</span></div>
|
||||
<div class="card-body">
|
||||
<form t-attf-action="/my/visit/{{visit.id}}/save" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3"><label class="form-label">Client Name</label>
|
||||
<input type="text" name="client_name" class="form-control" t-att-value="visit.client_name"/></div>
|
||||
<div class="col-md-6 mb-3"><label class="form-label">Phone</label>
|
||||
<input type="text" name="client_phone" class="form-control" t-att-value="visit.client_phone"/></div>
|
||||
<div class="col-md-6 mb-3"><label class="form-label">Email</label>
|
||||
<input type="email" name="client_email" class="form-control" t-att-value="visit.client_email"/></div>
|
||||
<div class="col-md-6 mb-3"><label class="form-label">Address</label>
|
||||
<input type="text" name="client_address" class="form-control" t-att-value="visit.client_address"/></div>
|
||||
</div>
|
||||
<div class="mb-3" t-if="visit.has_mod_items">
|
||||
<label class="form-label">Income under March of Dimes threshold?
|
||||
<span class="text-muted small">(MOD covers up to $15k/person, lifetime)</span></label>
|
||||
<select name="x_fc_income_under_mod_threshold" class="form-select">
|
||||
<option value="unknown" t-att-selected="visit.x_fc_income_under_mod_threshold == 'unknown'">Unknown</option>
|
||||
<option value="yes" t-att-selected="visit.x_fc_income_under_mod_threshold == 'yes'">Yes — under threshold (full $15k)</option>
|
||||
<option value="no" t-att-selected="visit.x_fc_income_under_mod_threshold == 'no'">No — over threshold (may be denied/partial)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary">Save client details</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complete -->
|
||||
<form t-attf-action="/my/visit/{{visit.id}}/complete" method="post" t-if="visit.assessment_count">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<button type="submit" class="btn btn-primary btn-lg">Complete visit & create sale orders →</button>
|
||||
<p class="text-muted small mt-2">Creates one sale order per funding workflow (ADP / March of Dimes / private / ...).</p>
|
||||
</form>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -1,108 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form -->
|
||||
<record id="view_fusion_assessment_visit_form" model="ir.ui.view">
|
||||
<field name="name">fusion.assessment.visit.form</field>
|
||||
<field name="model">fusion.assessment.visit</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_complete_visit" type="object"
|
||||
string="Complete Visit & Create Sale Orders"
|
||||
class="btn-primary" invisible="state == 'done'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="measuring,client_pending,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Client">
|
||||
<field name="partner_id"/>
|
||||
<field name="client_name"/>
|
||||
<field name="client_phone"/>
|
||||
<field name="client_email"/>
|
||||
</group>
|
||||
<group string="Visit">
|
||||
<field name="visit_date"/>
|
||||
<field name="sales_rep_id"/>
|
||||
<field name="authorizer_id"/>
|
||||
<field name="has_mod_items" invisible="1"/>
|
||||
<field name="x_fc_income_under_mod_threshold"
|
||||
invisible="not has_mod_items"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert" invisible="not has_mod_items">
|
||||
<strong>March of Dimes:</strong> covers up to $15,000 per person
|
||||
(lifetime), income-gated. Confirm the client's income status above.
|
||||
</div>
|
||||
<notebook>
|
||||
<page string="Accessibility Assessments">
|
||||
<field name="accessibility_assessment_ids" readonly="1">
|
||||
<list>
|
||||
<field name="reference"/>
|
||||
<field name="assessment_type"/>
|
||||
<field name="x_fc_funding_source"/>
|
||||
<field name="state"/>
|
||||
<field name="sale_order_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="ADP Assessments">
|
||||
<field name="adp_assessment_ids" readonly="1">
|
||||
<list>
|
||||
<field name="equipment_type"/>
|
||||
<field name="state"/>
|
||||
<field name="sale_order_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Sale Orders">
|
||||
<field name="sale_order_ids" readonly="1">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="x_fc_sale_type"/>
|
||||
<field name="state"/>
|
||||
<field name="amount_total"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- List -->
|
||||
<record id="view_fusion_assessment_visit_list" model="ir.ui.view">
|
||||
<field name="name">fusion.assessment.visit.list</field>
|
||||
<field name="model">fusion.assessment.visit</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="visit_date"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="client_name"/>
|
||||
<field name="sales_rep_id"/>
|
||||
<field name="assessment_count"/>
|
||||
<field name="sale_order_count"/>
|
||||
<field name="state"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action + menu -->
|
||||
<record id="action_fusion_assessment_visit" model="ir.actions.act_window">
|
||||
<field name="name">Assessment Visits</field>
|
||||
<field name="res_model">fusion.assessment.visit</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_assessment_visit"
|
||||
name="Assessment Visits"
|
||||
parent="sale.sale_menu_root"
|
||||
action="action_fusion_assessment_visit"
|
||||
sequence="50"/>
|
||||
</odoo>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Repairs',
|
||||
'version': '19.0.2.2.6',
|
||||
'version': '19.0.2.3.0',
|
||||
'category': 'Inventory/Repairs',
|
||||
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||
'description': """
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
We would love to hear how it went - your feedback helps other clients
|
||||
find us and helps us improve.
|
||||
</p>
|
||||
<!-- H4: URL-encode the company name so the fallback URL survives ampersands + spaces. -->
|
||||
<t t-set="review_url" t-value="object.company_id.x_fc_google_review_url or ('https://www.google.com/search?' + url_encode({'q': object.company_id.name or ''}))"/>
|
||||
<!-- H4: build a fallback search URL without url_encode (not available in the mail QWeb render context); replace spaces so the URL survives. -->
|
||||
<t t-set="review_url" t-value="object.company_id.x_fc_google_review_url or ('https://www.google.com/search?q=' + (object.company_id.name or '').replace(' ', '+'))"/>
|
||||
<div style="text-align:center;margin:0 0 24px 0;">
|
||||
<a t-att-href="review_url"
|
||||
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
|
||||
@@ -433,6 +433,12 @@
|
||||
is due for its next scheduled maintenance visit on
|
||||
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
|
||||
</p>
|
||||
<div t-if="object.x_fc_maintenance_fee" style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;">
|
||||
Maintenance visit fee:
|
||||
<strong><t t-out="object.x_fc_maintenance_fee" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></strong><span style="opacity:0.6;"> + applicable tax</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin:0 0 24px 0;">
|
||||
<a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}"
|
||||
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
|
||||
|
||||
@@ -80,6 +80,26 @@ class FusionRepairMaintenanceContract(models.Model):
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee', currency_field='currency_id',
|
||||
help='Flat fee shown to the client for this maintenance visit.',
|
||||
)
|
||||
x_fc_source = fields.Selection(
|
||||
[('sale', 'New Sale'), ('backfill', 'Backfill'),
|
||||
('claims', 'Claims Bridge'), ('manual', 'Manual')],
|
||||
string='Source', default='manual', index=True,
|
||||
)
|
||||
x_fc_source_sale_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Source Sale Line', index=True, copy=False,
|
||||
)
|
||||
x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False)
|
||||
x_fc_policy_category_id = fields.Many2one(
|
||||
'fusion.repair.product.category', string='Maintenance Policy',
|
||||
)
|
||||
|
||||
_booking_token_unique = models.Constraint(
|
||||
'unique(booking_token)',
|
||||
'Booking token must be unique.',
|
||||
@@ -195,11 +215,18 @@ class FusionRepairMaintenanceContract(models.Model):
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
def _fc_maintenance_anchor_date(self, line):
|
||||
"""Best-available delivery anchor: commitment_date -> date_order -> today.
|
||||
(Non-ADP / lift units lack a delivery date; this fallback chain handles them.)"""
|
||||
so = line.order_id
|
||||
anchor = so.commitment_date or so.date_order
|
||||
return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self)
|
||||
|
||||
def _spawn_maintenance_contracts(self):
|
||||
"""Create maintenance contracts for any delivered SO line whose
|
||||
product has x_fc_maintenance_interval_months > 0."""
|
||||
"""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()
|
||||
today = fields.Date.context_today(self)
|
||||
for so in self:
|
||||
if so.state not in ('sale', 'done'):
|
||||
continue
|
||||
@@ -207,21 +234,42 @@ class SaleOrder(models.Model):
|
||||
product = line.product_id
|
||||
if not product:
|
||||
continue
|
||||
interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0
|
||||
if interval <= 0:
|
||||
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
|
||||
existing = Contract.search([
|
||||
('partner_id', '=', so.partner_id.id),
|
||||
('product_id', '=', product.id),
|
||||
('original_sale_order_id', '=', so.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
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
|
||||
Contract.create({
|
||||
'partner_id': so.partner_id.id,
|
||||
'product_id': product.id,
|
||||
'original_sale_order_id': so.id,
|
||||
'interval_months': interval,
|
||||
'next_due_date': today + relativedelta(months=interval),
|
||||
'state': 'active',
|
||||
})
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@ class ProductTemplate(models.Model):
|
||||
help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
|
||||
'with this recurring interval. Phase 3 feature.',
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee (override)', currency_field='currency_id',
|
||||
help='Per-product override of the category maintenance fee. 0 = use the category fee.',
|
||||
)
|
||||
x_fc_intake_template_id = fields.Many2one(
|
||||
'fusion.repair.intake.template',
|
||||
string='Intake Template Override',
|
||||
|
||||
@@ -53,6 +53,31 @@ class FusionRepairProductCategory(models.Model):
|
||||
help='Default intake question set shown when this category is selected.',
|
||||
)
|
||||
|
||||
# ── Maintenance policy (per equipment type) ──────────────────────────
|
||||
x_fc_maintenance_enabled = fields.Boolean(
|
||||
string='Offer Maintenance',
|
||||
help='If set, units in this category are enrolled in recurring preventive '
|
||||
'maintenance on sale (and via the backfill wizard).',
|
||||
)
|
||||
x_fc_maintenance_interval_months = fields.Integer(
|
||||
string='Maintenance Interval (Months)', default=6,
|
||||
help='Default months between preventive maintenance visits for this category. '
|
||||
'Overridden by the product field of the same name when that is > 0.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee', currency_field='currency_id',
|
||||
help='Flat fee shown to the client for a maintenance visit of this equipment type.',
|
||||
)
|
||||
x_fc_maintenance_service_product_id = fields.Many2one(
|
||||
'product.product', string='Maintenance Service Product',
|
||||
help='Optional product used when drafting the priced visit line (Plan 2). '
|
||||
'Falls back to a generic visit product.',
|
||||
)
|
||||
|
||||
_code_unique = models.Constraint(
|
||||
'unique(code)',
|
||||
'Category code must be unique.',
|
||||
|
||||
@@ -247,6 +247,7 @@ class SaleOrder(models.Model):
|
||||
# Bundle 9: spawn store labor warranties for any product line with
|
||||
# x_fc_labor_warranty_years > 0.
|
||||
self._fc_spawn_labor_warranties()
|
||||
self._spawn_maintenance_contracts()
|
||||
return res
|
||||
|
||||
def _fc_spawn_labor_warranties(self):
|
||||
|
||||
2
fusion_repairs/tests/__init__.py
Normal file
2
fusion_repairs/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_maintenance_foundation
|
||||
100
fusion_repairs/tests/test_maintenance_foundation.py
Normal file
100
fusion_repairs/tests/test_maintenance_foundation.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMaintenanceFoundation(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'Mrs. Test Client'})
|
||||
cls.category = cls.env['fusion.repair.product.category'].create({
|
||||
'name': 'Stair Lift', 'code': 'stairlift_test',
|
||||
'equipment_class': 'lift_elevating', 'safety_critical': True,
|
||||
'x_fc_maintenance_enabled': True,
|
||||
'x_fc_maintenance_interval_months': 6,
|
||||
'x_fc_maintenance_fee': 149.0,
|
||||
})
|
||||
|
||||
# ---- Tasks 1/2/3: fields exist ----
|
||||
def test_category_policy_fields(self):
|
||||
self.assertTrue(self.category.x_fc_maintenance_enabled)
|
||||
self.assertEqual(self.category.x_fc_maintenance_interval_months, 6)
|
||||
self.assertEqual(self.category.x_fc_maintenance_fee, 149.0)
|
||||
self.assertTrue(self.category.currency_id)
|
||||
|
||||
def test_product_fee_override_field(self):
|
||||
tmpl = self.env['product.template'].create({
|
||||
'name': 'Handicare Freecurve Stairlift',
|
||||
'x_fc_repair_category_id': self.category.id,
|
||||
'x_fc_maintenance_fee': 199.0,
|
||||
})
|
||||
self.assertEqual(tmpl.x_fc_maintenance_fee, 199.0)
|
||||
|
||||
def test_contract_extension_fields(self):
|
||||
product = self.env['product.product'].create({'name': 'Unit'})
|
||||
c = self.env['fusion.repair.maintenance.contract'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': product.id,
|
||||
'next_due_date': '2026-12-01',
|
||||
'x_fc_source': 'sale',
|
||||
'x_fc_device_serial': 'SN-123',
|
||||
'x_fc_maintenance_fee': 149.0,
|
||||
})
|
||||
self.assertEqual(c.x_fc_source, 'sale')
|
||||
self.assertEqual(c.x_fc_device_serial, 'SN-123')
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
|
||||
|
||||
# ---- Task 4: spawn priced contracts on sale confirm ----
|
||||
def _make_product(self, **kw):
|
||||
vals = {'name': 'Stairlift Unit', 'type': 'consu',
|
||||
'x_fc_repair_category_id': self.category.id}
|
||||
vals.update(kw)
|
||||
return self.env['product.product'].create(vals)
|
||||
|
||||
def _confirm_so(self, product, commitment='2026-01-10'):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'commitment_date': commitment,
|
||||
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
|
||||
})
|
||||
so.action_confirm()
|
||||
return so
|
||||
|
||||
def _contracts_for(self, so):
|
||||
return self.env['fusion.repair.maintenance.contract'].search(
|
||||
[('original_sale_order_id', '=', so.id)])
|
||||
|
||||
def test_no_contract_when_category_not_maintainable(self):
|
||||
cat = self.env['fusion.repair.product.category'].create(
|
||||
{'name': 'Cane', 'code': 'cane_test', 'x_fc_maintenance_enabled': False})
|
||||
so = self._confirm_so(self._make_product(x_fc_repair_category_id=cat.id))
|
||||
self.assertFalse(self._contracts_for(so))
|
||||
|
||||
def test_contract_created_via_category_policy(self):
|
||||
so = self._confirm_so(self._make_product())
|
||||
contracts = self._contracts_for(so)
|
||||
self.assertEqual(len(contracts), 1)
|
||||
c = contracts
|
||||
self.assertEqual(c.interval_months, 6)
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
|
||||
self.assertEqual(c.x_fc_source, 'sale')
|
||||
self.assertEqual(c.x_fc_policy_category_id, self.category)
|
||||
# anchor = commitment_date (2026-01-10) + 6 months
|
||||
self.assertEqual(str(c.next_due_date), '2026-07-10')
|
||||
|
||||
def test_product_override_beats_category(self):
|
||||
p = self._make_product()
|
||||
p.product_tmpl_id.x_fc_maintenance_interval_months = 3
|
||||
p.product_tmpl_id.x_fc_maintenance_fee = 199.0
|
||||
so = self._confirm_so(p)
|
||||
c = self._contracts_for(so)
|
||||
self.assertEqual(c.interval_months, 3)
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 199.0)
|
||||
|
||||
def test_idempotent_on_reconfirm(self):
|
||||
p = self._make_product()
|
||||
so = self._confirm_so(p)
|
||||
so._spawn_maintenance_contracts() # call again -> no duplicate
|
||||
self.assertEqual(len(self._contracts_for(so)), 1)
|
||||
@@ -57,6 +57,13 @@
|
||||
action="action_repair_part_order"
|
||||
sequence="38"/>
|
||||
|
||||
<!-- Configuration parent: must be defined before the children that reference it below -->
|
||||
<menuitem id="menu_fusion_repairs_configuration"
|
||||
name="Configuration"
|
||||
parent="menu_fusion_repairs_root"
|
||||
sequence="90"
|
||||
groups="fusion_repairs.group_fusion_repairs_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_emergency_charges"
|
||||
name="Emergency Surcharges"
|
||||
parent="menu_fusion_repairs_configuration"
|
||||
@@ -100,13 +107,6 @@
|
||||
action="action_repair_labor_warranty"
|
||||
sequence="36"/>
|
||||
|
||||
<!-- Configuration -->
|
||||
<menuitem id="menu_fusion_repairs_configuration"
|
||||
name="Configuration"
|
||||
parent="menu_fusion_repairs_root"
|
||||
sequence="90"
|
||||
groups="fusion_repairs.group_fusion_repairs_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_categories"
|
||||
name="Equipment Categories"
|
||||
parent="menu_fusion_repairs_configuration"
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Maintenance Policy">
|
||||
<field name="x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_interval_months" invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_fee" invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_service_product_id" invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
<field name="description" placeholder="Describe what equipment falls into this category..."/>
|
||||
</sheet>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user