Compare commits
99 Commits
feat/fusio
...
claude/ser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d06ff77f | ||
|
|
53fe13344d | ||
|
|
423f288507 | ||
|
|
a86f20017d | ||
|
|
7426501555 | ||
|
|
3e787a1b24 | ||
|
|
6f006e24ad | ||
|
|
ba6aeaaca9 | ||
|
|
27577dd51a | ||
|
|
a10b7425f7 | ||
|
|
dcd4955bb7 | ||
|
|
a2277b481c | ||
|
|
197030a188 | ||
|
|
c97a0d985c | ||
|
|
e6bbf566ca | ||
|
|
86e9fdead8 | ||
|
|
c80ffa1b2c | ||
|
|
97880765b5 | ||
|
|
587988bb06 | ||
|
|
a209648ed9 | ||
|
|
ea6b3fe2e9 | ||
|
|
b23eaa5695 | ||
|
|
489312365e | ||
|
|
6728197570 | ||
|
|
eea4dad048 | ||
|
|
63694eccb1 | ||
|
|
252716156c | ||
|
|
dfa266d691 | ||
|
|
7b8364eb58 | ||
|
|
4e5e9f4c91 | ||
|
|
f84c22c743 | ||
|
|
46d19fd581 | ||
|
|
56ca82c611 | ||
|
|
d457b86eaa | ||
|
|
92e8a18fcb | ||
|
|
245e551c68 | ||
|
|
a022eaaabe | ||
|
|
0e6bb7b676 | ||
|
|
d5d410f6d0 | ||
|
|
41141a75e8 | ||
|
|
d512dfccf0 | ||
|
|
5e9576ed8f | ||
|
|
80d9a960e7 | ||
|
|
3fe5d5c17c | ||
|
|
190b394001 | ||
|
|
b5a300f439 | ||
|
|
f0400114f9 | ||
|
|
25ef7832f5 | ||
|
|
600e11fabb | ||
|
|
5e3e6b5319 | ||
|
|
774d21863e | ||
|
|
2f8b6b3ae0 | ||
|
|
837198fc8a | ||
|
|
5a3c660322 | ||
|
|
235c8fba39 | ||
|
|
b52b8758a1 | ||
|
|
910ccd0fc6 | ||
|
|
2b0add3a2e | ||
|
|
f00a039fc2 | ||
|
|
5646c97f67 | ||
|
|
fec72a70c1 | ||
|
|
d531faad12 | ||
|
|
951cad0f81 | ||
|
|
acd1fc9f8f | ||
|
|
5424c785d9 | ||
|
|
ae256b4480 | ||
|
|
696f5da662 | ||
|
|
fc3fd513a9 | ||
|
|
a19a299c7f | ||
|
|
78fa8f07ee | ||
|
|
71f4c41d5c | ||
|
|
2f6a8b33a9 | ||
|
|
4b832e7445 | ||
|
|
f67cefc213 | ||
|
|
658611457e | ||
|
|
4df35448c2 | ||
|
|
1d6797f0d2 | ||
|
|
4622521729 | ||
|
|
40a29081bf | ||
|
|
11ab261ad9 | ||
|
|
2285b7b814 | ||
|
|
859a327738 | ||
|
|
a52f2bbebd | ||
|
|
9a8e1d7ab5 | ||
|
|
837e7b09b7 | ||
|
|
ed91135a3f | ||
|
|
451fc5eafd | ||
|
|
7fcf38ca82 | ||
|
|
64a202ff6e | ||
|
|
13fabb0e79 | ||
|
|
20de9a6b69 | ||
|
|
21cfd55419 | ||
|
|
89467432a7 | ||
|
|
319de06ca6 | ||
|
|
e0ddd9ef40 | ||
|
|
0499a1ad2e | ||
|
|
b17bd615bf | ||
|
|
e36aaab306 | ||
|
|
37efc5b858 |
56
CLAUDE.md
56
CLAUDE.md
@@ -35,6 +35,8 @@
|
||||
|
||||
16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u <newname>`; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... import`.
|
||||
|
||||
17. **`url_encode` (and werkzeug url helpers) are NOT available in the Odoo 19 `mail.template` QWeb render context.** Using `url_encode({...})` inside a template `body_html` (e.g. to build a fallback link) makes the template fail Odoo's save-time render validation **at install**, surfacing as the opaque `ParseError: ... Oops! We couldn't save your template due to an issue with this value: <the entire body html>` (the real `NameError` is hidden, and `--log-handler odoo.tools.convert:DEBUG` does NOT reveal it). Build URLs with plain string methods instead: `'https://…?q=' + (value or '').replace(' ', '+')`. Found installing `fusion_repairs` (post-visit NPS template). **That same opaque "issue with this value" error wraps ANY render failure in a mail.template body** — when you see it, suspect an undefined name / bad field reference in the template, not malformed XML.
|
||||
|
||||
## Card Styling — Copy Odoo's Kanban Pattern
|
||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||
```css
|
||||
@@ -96,7 +98,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
|
||||
## Module-Specific Notes
|
||||
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
|
||||
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 1–11 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_portal`.
|
||||
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.3.0`** (Plan-1 maintenance foundation added 2026-06-02). **NOT Community-installable** — it transitively pulls in Enterprise `ai` + `knowledge` (`fusion_repairs → fusion_portal → fusion_claims → ai`; `fusion_portal → knowledge`), so it can NOT be installed or tested on local `odoo-modsdev` (Community) — the old `-d fusion-dev -u fusion_repairs` recipe does NOT work. **Test on Enterprise:** an isolated `westin-fr-test` DB on the `odoo-westin` host (clone of prod `westin-v19`; a fresh-DB clone install also needs a one-time orphaned-FK cleanup because prod has orphaned account/tax m2m rows). First-ever clean install surfaced + fixed 2 bugs (url_encode → rule 17; menu parent defined after its children) in commit `903ceb10`. **Not production-deployed** to Westin yet. **Test-runner gotchas on that prod-config container:** `--test-enable` SILENTLY SKIPS all tests without `--workers 0`; the conf's `log_level=warn` hides test output (add `--log-level=test`); the post_install phase also trips on a pre-existing module, so verify behaviour via `odoo shell` rather than the test runner. `mail_template_data.xml` is `noupdate=1` → template edits load on a FRESH install (the prod deploy) but NOT on `-u` of an already-installed DB. Outstanding: maintenance booking (Plan 2), visit log (Plan 3), backfill wizard (Plan 4), office follow-up crons (Plan 5), RingCentral SMS.
|
||||
- **fusion_portal** (formerly `fusion_authorizer_portal`) — authorizer/sales-rep portal; **ENTERPRISE-only** (depends `knowledge` → cannot run on local Community; verify on a westin clone, see *Westin Prod* below). **Assessment-visit flow LIVE on westin, v19.0.2.10.1.** A `fusion.assessment.visit` bundles the assessments from one home visit and, on completion (`action_complete_visit`), groups them by funding workflow (`x_fc_sale_type`) into ONE draft sale order per workflow (MoD/ADP/ODSP/WSIB/private/hardship/insurance) — never one combined SO, never one-per-item-within-a-funding. ADP devices group into one order (combination guard: ≤1 seated {wheelchair/powerchair/scooter} + ≤1 walker); accessibility items group per funding. Reps enter via the "Start a Visit" dashboard tile → `/my/visit/new`; the express/accessibility forms carry `?visit_id=` and defer SO creation to the visit. Renaming the technical name needs a DB rename — see [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql).
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
@@ -138,6 +141,19 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
|
||||
- `fusionapps.code_snippets` — reference code
|
||||
- `fusionapps.quick_commands` — deployment and admin commands
|
||||
|
||||
## Westin Prod — Deploy & Clone-Verify (fusion_portal et al.)
|
||||
|
||||
Westin prod: host `odoo-westin`, app container `odoo-dev-app`, db container `odoo-dev-db`, DB `westin-v19` (user `odoo`, pw `DevSecure2025!`), addons `/opt/odoo/custom-addons` → `/mnt/extra-addons`, Enterprise `/mnt/enterprise-addons`, conf `/etc/odoo/odoo.conf`. ENTERPRISE env — modules depending on `knowledge` (fusion_portal → fusion_claims) cannot run on local Community, so verify on a clone before prod.
|
||||
|
||||
**Clone-verify a change (prod-safe, isolated — prod files + live DB untouched):**
|
||||
1. Clone online: `docker exec -e PGPASSWORD='DevSecure2025!' odoo-dev-db sh -c 'dropdb -U odoo --if-exists westin-v19-visittest; createdb -U odoo -O odoo westin-v19-visittest && pg_dump -U odoo westin-v19 | psql -U odoo -q -d westin-v19-visittest'` (~2 min, ~152M -Fc).
|
||||
2. Stage the branch module into an isolated dir INSIDE the addons path: `/opt/odoo/custom-addons/_test/<module>`, then `-u <module> --stop-after-init --no-http --db_host db --db_port 5432 --db_user odoo --db_password 'DevSecure2025!' --addons-path=/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,/mnt/extra-addons/_test,/mnt/enterprise-addons,/mnt/extra-addons`. The `/mnt/extra-addons/_test` prefix SHADOWS prod's copy (first matching path wins); deps load from the real `/mnt/extra-addons`.
|
||||
3. Smoke-test via `odoo shell -d westin-v19-visittest` (same addons-path); `env.cr.rollback()` at the end. To exercise email paths WITHOUT sending: `UPDATE ir_mail_server SET active=false;` AND in the shell `env['ir.mail_server'].__class__.send_email = lambda self, message, *a, **k: 'noop'` (`odoo shell` rejects `--smtp-server`).
|
||||
|
||||
**THE ORPHANED-TAX-FK TRAP** (cost real diagnosis time): westin-v19 has ~3300 orphaned rows in `product_taxes_rel` + ~3300 in `product_supplier_taxes_rel` (`tax_id` → deleted `account_tax`), under FKs that are `convalidated=true` (taxes deleted via an FK-bypassing path; PG never re-checks a validated constraint). A plain `pg_dump | psql` clone can't recreate a *validating* FK over orphaned data → the FK is lost on the clone → Odoo `check_foreign_keys` tries to add it → `ForeignKeyViolation: Key (tax_id)=(N) is not present in account_tax` → "Failed to load registry". **Fix ON THE CLONE only:** `DELETE FROM <t> WHERE tax_id NOT IN (SELECT id FROM account_tax)` across every `%_rel` table with a tax column. **Prod `-u` is SAFE without touching the orphans** — prod's FK already exists, so Odoo skips it (it never re-validates a present FK); proven empirically by replicating FK-present+orphan on a clone and running `-u` (exit 0, orphan untouched). Owner is auditing the orphans — do NOT delete them on prod without sign-off.
|
||||
|
||||
**Deploy:** backup (`docker exec ... pg_dump -Fc -U odoo westin-v19 > /opt/odoo/backups/<name>.dump` + `cp -r` the module dir to `/opt/odoo/backups/` — OUTSIDE the addons path, never a `*.bak` dir inside it) → `scp` branch to `/opt/odoo/staging/<module>` → swap into `/opt/odoo/custom-addons/<module>` → `-u <module>` → `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` → `docker restart odoo-dev-app`. **Gate the restart on `-u` exit 0**; on failure restore the dir backup and do NOT restart. When a feature branch predates main's other merges, merge to `main` **surgically** (temp worktree off `origin/main` + `git checkout <branch> -- <module>` → commit → fast-forward push) so you don't revert parallel sessions' work.
|
||||
|
||||
## Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)
|
||||
|
||||
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
|
||||
@@ -232,3 +248,41 @@ catches undefined names instantly.
|
||||
open the systray helpdesk dialog. The Mine/All toggle appears for the owner; "All" shows
|
||||
all 50 ENTECH tickets, "Mine" shows the count matching the owner's profile email.
|
||||
Tracebacks live in `/var/log/odoo/odoo-server.log` on entech (LXC 111 / pve-worker5).
|
||||
|
||||
## Fusion Centralized Billing (`fusion_centralize_billing`) — engine + test harness
|
||||
|
||||
Odoo (`odoo-nexa`, live DB `nexamain`) is being made the single billing brain for every
|
||||
NexaSystems app (NexaCloud, NexaDesk/Fusion-Chat, NexaMaps), **superseding Lago**. The
|
||||
module adds only the metering + integration layer (service registry, identity links,
|
||||
metric/charge catalog, aggregate-push usage engine, inbound Lago-shaped REST API at
|
||||
`/api/billing/v1/*`, outbound HMAC webhooks, dual-run reconciliation); all financial
|
||||
behaviour is native Odoo **Enterprise** (`sale_subscription` + `payment_stripe` +
|
||||
`account_accountant`). Design + rollout live in `docs/superpowers/specs/`
|
||||
(`2026-05-27-nexa-billing-centralized-design.md` = architecture;
|
||||
`2026-06-02-nexacloud-odoo-billing-cutover-design.md` = NexaCloud pilot: build → import →
|
||||
dual-run → gated flip) and `docs/superpowers/plans/`.
|
||||
|
||||
**Testing it — NOT on local `odoo-modsdev` (community) and NEVER `-u` against live `nexamain`.**
|
||||
It needs Enterprise deps, so tests run on `odoo-nexa` in an **isolated throwaway container**
|
||||
against a **fresh** DB with the Canadian localization:
|
||||
```
|
||||
ssh odoo-nexa
|
||||
# fresh DB (inside odoo-nexa-db): dropdb --if-exists fcb_test; createdb fcb_test
|
||||
cp -a /opt/odoo/custom-addons /opt/odoo/custom-addons-staging # edit/sync HERE, never the live module dir
|
||||
docker run --rm --network odoo_odoo-network \
|
||||
-v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \
|
||||
-v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro -v /opt/odoo/staging-data:/var/lib/odoo \
|
||||
odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test --db_host=db --db_user=odoo \
|
||||
--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \
|
||||
--without-demo=all --test-enable --test-tags /fusion_centralize_billing \
|
||||
-i l10n_ca,fusion_centralize_billing --stop-after-init --no-http
|
||||
```
|
||||
Iterate with `-u fusion_centralize_billing` (reuse fcb_test). Gotchas that cost hours:
|
||||
- **`l10n_ca` is required** — the ledger tests need a Canadian CoA + active CAD + 13% HST.
|
||||
- A **prod clone is the wrong base** — its existing rows collide with fixed-code test fixtures
|
||||
(`nexacloud` service / `cpu_seconds` metric) across 5 test files.
|
||||
- odoo.conf sets `log_level=warn`, so **passing tests log nothing** — exit 0 alone does NOT
|
||||
prove tests ran (a tag matching zero tests is also exit 0). Confirm execution with
|
||||
`--log-handler=odoo.addons.fusion_centralize_billing.tests:INFO` (look for `Starting
|
||||
<Class>.<method>`). The **exit code is authoritative** (1 on any failure).
|
||||
- Do **NOT** pass `--workers=0` (blanks captured stdout) or `--logfile=/dev/stdout` (errors out).
|
||||
|
||||
127
docs/superpowers/EXECUTE-technician-service-booking.md
Normal file
127
docs/superpowers/EXECUTE-technician-service-booking.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# KICKOFF BRIEF — Implement "Technician Service Booking & Auto-Quote" (hands-off)
|
||||
|
||||
You are a fresh Claude Code session. **Implement this feature end-to-end, autonomously, from the
|
||||
plans below.** The design is already locked through brainstorming — **do NOT re-design or
|
||||
re-brainstorm.** Build it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mission
|
||||
|
||||
Replace the raw `fusion.technician.task` booking modal with a polished **OWL "Book a Service"
|
||||
wizard** that: captures the client (incl. brand-new clients inline), books the technician task,
|
||||
prices the call-out from an **editable rate table**, and **auto-creates a draft repair Sale Order**
|
||||
— with correct, consistent timezone handling. Works in dark + light.
|
||||
|
||||
## 2. Read these first, in order
|
||||
|
||||
1. `K:\Github\Odoo-Modules\CLAUDE.md` (repo Odoo-19 rules) + the global `K:\Github\CLAUDE.md`.
|
||||
2. Spec: `docs/superpowers/specs/2026-06-03-technician-service-booking-design.md`
|
||||
3. **Plan 1** (do first): `docs/superpowers/plans/2026-06-03-service-rates-foundation-plan.md`
|
||||
4. **Plan 2** (do second): `docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md`
|
||||
5. UI source of truth (port its markup/CSS): `docs/superpowers/mockups/technician-booking-wizard.html`
|
||||
|
||||
The plans are bite-sized (TDD, exact files, full code). They are the authority — follow them
|
||||
task-by-task. The spec/mockup are context.
|
||||
|
||||
## 3. Method
|
||||
|
||||
- Use the **`superpowers:subagent-driven-development`** skill (the plan headers require it). One
|
||||
task at a time; write test → implement → verify → **commit per task** with the messages in the plan.
|
||||
- **Order: Plan 1 fully, then Plan 2** (Plan 2 consumes Plan 1's `fusion.service.rate`).
|
||||
- Before writing any model/view/OWL code, obey repo rule #1: **read the real reference from Docker
|
||||
first** (`docker exec odoo-modsdev-app cat …` or, for the Enterprise classes, read the on-disk
|
||||
source) — never code Odoo APIs from memory. The plans flag the specific signatures to confirm
|
||||
(`_get_local_tz`, `_compute_datetimes`, `_calculate_travel_time`, real task field names like
|
||||
`in_store`/`client_name`/`address_lat`, the `crm.tag` vs `sale.order` tag model).
|
||||
|
||||
## 4. Branch
|
||||
|
||||
```bash
|
||||
git -C K:\Github\Odoo-Modules checkout main
|
||||
git -C K:\Github\Odoo-Modules checkout -b claude/technician-service-booking
|
||||
```
|
||||
Create it **off `main`** — NOT off `claude/fusion-schedule-audit-fixes` (that branch has unrelated
|
||||
calendar-sync fixes). The spec/plans/mockup are already on disk under `docs/superpowers/`; keep them.
|
||||
|
||||
## 5. Hard constraints (do not violate)
|
||||
|
||||
- **Odoo 19 idioms** (from CLAUDE.md): declarative `models.Constraint` / `models.Index` (never
|
||||
`_sql_constraints`); `group_ids` not `groups_id`; HTTP routes `type="jsonrpc"`; backend OWL uses
|
||||
**standalone `rpc()`** from `@web/core/network/rpc` (not `useService("rpc")`), client action
|
||||
`static props = ["*"]`; **dark mode** = branch on `$o-webclient-color-scheme` at SCSS compile
|
||||
time and register the SCSS in **both** `web.assets_backend` **and** `web.assets_web_dark`; new
|
||||
fields use the **`x_fc_`** prefix; **Canadian English**; any `message_post(body=…)` HTML wrapped
|
||||
in `Markup()`.
|
||||
- **Enterprise-only:** `fusion_claims` pulls `ai` → it **cannot install on local Community
|
||||
(`odoo-modsdev`)**. Do **not** attempt `-d modsdev -u fusion_claims`. (`fusion_tasks` alone may
|
||||
install locally — the tz-fix test in Plan 2 Task 1 can be tried there; everything else is clone-only.)
|
||||
- **The design is LOCKED** — implement exactly §6 below; don't add scope or re-open decisions.
|
||||
|
||||
## 6. Locked design (build exactly this)
|
||||
|
||||
- **Time:** 12-hour **AM/PM** entry on the wizard (custom control — Odoo's native widget is 24h).
|
||||
Fix the `fusion_tasks` tz bug: the `_inverse_datetime_*` methods must use `self._get_local_tz()`
|
||||
(same resolver as `_compute_datetimes`), not `self.env.user.tz`.
|
||||
- **Client:** inline **new-client** (name / phone / email / address) on the page; **no forced SO**
|
||||
(relax `fusion_claims` `_check_order_link` to a no-op); find-or-create the `res.partner` on save
|
||||
(match by email then phone).
|
||||
- **View:** a **full OWL client action** wizard (complete design freedom), ported from the mockup,
|
||||
dark + light.
|
||||
- **Pricing → SO:** pick service type → call-out fee → **auto draft repair `sale.order`** with the
|
||||
call-out line **+ auto per-km line** for Rush/After-Hours (qty = `travel_distance_km × 2`,
|
||||
$0.70/km). On-screen **estimate is UI-only** (labour/parts added later as actuals). Tag the SO
|
||||
(`x_fc_is_service_repair` + a "Service Repair" tag).
|
||||
- **Rates are an editable table** — `fusion.service.rate` with a **Service Rates** menu. The card
|
||||
only **seeds** it (`noupdate=1`). Pricing is read from this table, never hardcoded.
|
||||
- **Rate card seed:** Standard call $95 / Rush $120 / After-Hours $140; Lift & Elevating $160 /
|
||||
**Rush $185** / **After-Hours $205** (the $185/$205 are *suggested* fills — seed them but they're
|
||||
confirm-pending; leave a code comment). Labour: on-site $85, in-shop $75 (reuse existing `LABOR`
|
||||
product), lift $110. Per-km $0.70 ×2-way. Delivery/setup: local $35 / outside $60 / rush $60+km /
|
||||
lift-chair $120 / bed $120 / stairlift $300 / removal $300. **In-shop = no call-out, labour @ $75.**
|
||||
- **Module split:** the tz fix goes in **`fusion_tasks`**; everything else (rate model, products,
|
||||
menu, resolver, SO builder, `action_book_from_wizard`, controller, OWL wizard, SCSS, entry point)
|
||||
goes in **`fusion_claims`**.
|
||||
|
||||
## 7. Verification (you probably can't reach the Enterprise clone — handle both cases)
|
||||
|
||||
- **Always do (no Odoo needed):** after each Python file, run `python -m py_compile <file>` and
|
||||
`python -m pyflakes <file>` (or `docker exec odoo-modsdev-app python3 -m pyflakes …`). **Fix every
|
||||
warning you introduce.** This is your local gate.
|
||||
- **Full tests + smoke require a Westin Enterprise clone.** A one-command harness already exists:
|
||||
`scripts/verify_service_booking.sh` (runs on the `odoo-westin` host: clones the DB, the
|
||||
orphaned-tax-FK cleanup, stages the branch, `-u` + tests, PASS/FAIL; `--deploy` ships on green).
|
||||
- If you have access to `odoo-westin`: push the branch, then run that script (verify-only first).
|
||||
- If you do **not**: finish all code, ensure `py_compile`/`pyflakes` are clean, **commit the
|
||||
branch task-by-task**, and clearly report **"clone-verification pending — run
|
||||
`scripts/verify_service_booking.sh` on odoo-westin."** Do not fake a green test.
|
||||
- **Never deploy to prod yourself.** Leave `--deploy` to the human.
|
||||
|
||||
## 8. Definition of done
|
||||
|
||||
- [ ] Branch `claude/technician-service-booking` off `main`.
|
||||
- [ ] Plan 1 + Plan 2 implemented, **committed task-by-task** with the plans' commit messages.
|
||||
- [ ] `py_compile` + `pyflakes` clean on every touched `.py`.
|
||||
- [ ] OWL wizard renders the mockup layout in **both** light and dark bundles.
|
||||
- [ ] Either **clone-verified GREEN** via the script, **or** branch committed + verification
|
||||
explicitly flagged pending (with the exact command to run).
|
||||
- [ ] A short final report: what was built, files changed, how to verify + deploy (`scripts/verify_service_booking.sh`),
|
||||
and the one open business item (confirm Lift Rush/After-Hours $185/$205).
|
||||
|
||||
## 9. Don't
|
||||
|
||||
- Don't test on `odoo-modsdev` (Community — `fusion_claims` won't install).
|
||||
- Don't re-brainstorm or change the design in §6.
|
||||
- Don't hardcode prices (they live in `fusion.service.rate`).
|
||||
- Don't deploy to prod or run `--deploy` — hand that to the human.
|
||||
- Don't change the suggested $185/$205 silently — keep them, flag them confirm-pending.
|
||||
|
||||
---
|
||||
|
||||
### Optional: launch it headless
|
||||
|
||||
```bash
|
||||
# from the repo root, on a machine with this checkout:
|
||||
claude -p "$(cat docs/superpowers/EXECUTE-technician-service-booking.md)" --permission-mode acceptEdits
|
||||
```
|
||||
…or just paste this file into a fresh Claude Code session and say "go".
|
||||
325
docs/superpowers/mockups/technician-booking-wizard.html
Normal file
325
docs/superpowers/mockups/technician-booking-wizard.html
Normal file
@@ -0,0 +1,325 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Book a Service — Mockup v2</title>
|
||||
<style>
|
||||
:root, [data-theme="light"] {
|
||||
--page:#eef0f3; --panel:#e6e9ed; --card:#ffffff; --border:#d8dadd;
|
||||
--text:#1f2430; --muted:#6b7280; --faint:#9ca3af;
|
||||
--field:#ffffff; --field-border:#cfd3d8; --field-focus:#3a8fb7;
|
||||
--chip:#f1f4f7; --shadow:0 1px 3px rgba(16,24,40,.08),0 1px 2px rgba(16,24,40,.06);
|
||||
--accent:#2e7aad; --accent-soft:#e8f2f8; --ok:#16a34a; --star:#f5b301; --money:#0f7d4e; --money-soft:#e7f6ee;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--page:#14161b; --panel:#1b1e24; --card:#22262d; --border:#343a42;
|
||||
--text:#e7eaef; --muted:#9aa3af; --faint:#6b7480;
|
||||
--field:#1a1d23; --field-border:#3a4049; --field-focus:#4aa3cf;
|
||||
--chip:#2a2f37; --shadow:0 1px 3px rgba(0,0,0,.4);
|
||||
--accent:#3a8fb7; --accent-soft:#19303d; --ok:#22c55e; --star:#f5b301; --money:#34d27f; --money-soft:#15281f;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--page); color:var(--text);
|
||||
font-family:'Inter','Helvetica Neue',Helvetica,Arial,system-ui,sans-serif; font-size:14px; }
|
||||
.wrap { max-width:1000px; margin:24px auto; padding:0 18px; }
|
||||
.dialog { background:var(--panel); border:1px solid var(--border); border-radius:16px;
|
||||
box-shadow:0 12px 40px rgba(16,24,40,.16); overflow:hidden; }
|
||||
.topbar { background:linear-gradient(135deg,#5ba848 0%,#3a8fb7 60%,#2e7aad 100%);
|
||||
padding:17px 24px; display:flex; align-items:center; justify-content:space-between; color:#fff; }
|
||||
.topbar h1 { font-size:19px; font-weight:700; margin:0; }
|
||||
.topbar .sub { font-size:12.5px; opacity:.9; margin-top:2px; }
|
||||
.theme-btn { background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.35); color:#fff;
|
||||
border-radius:20px; padding:6px 14px; font-size:12.5px; cursor:pointer; font-weight:600; }
|
||||
.stepper { display:flex; gap:6px; padding:11px 24px; background:var(--panel); border-bottom:1px solid var(--border); flex-wrap:wrap; }
|
||||
.step { font-size:11.5px; font-weight:600; color:var(--faint); padding:5px 13px; border-radius:20px; background:var(--chip); }
|
||||
.step.active { color:#fff; background:linear-gradient(135deg,#3a8fb7,#2e7aad); }
|
||||
.step.draft { margin-left:auto; color:var(--money); background:var(--money-soft); }
|
||||
|
||||
.body { padding:20px 24px 6px; }
|
||||
.grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
||||
@media (max-width:780px){ .grid { grid-template-columns:1fr; } }
|
||||
.card { background:var(--card); border:1px solid var(--border); border-radius:13px; padding:16px 17px; box-shadow:var(--shadow); }
|
||||
.card.span2 { grid-column:1 / -1; }
|
||||
.card h3 { margin:0 0 13px; font-size:11.5px; font-weight:700; letter-spacing:.7px; text-transform:uppercase;
|
||||
color:var(--muted); display:flex; align-items:center; gap:7px; }
|
||||
.card h3 .dot { width:7px; height:7px; border-radius:50%; background:linear-gradient(135deg,#5ba848,#2e7aad); }
|
||||
.card h3 .tag { margin-left:auto; font-size:10px; font-weight:700; color:var(--money); background:var(--money-soft);
|
||||
padding:2px 8px; border-radius:10px; letter-spacing:.3px; }
|
||||
|
||||
label.fl { display:block; font-size:12px; font-weight:600; color:var(--muted); margin:0 0 5px; }
|
||||
.row { margin-bottom:12px; } .row:last-child { margin-bottom:0; }
|
||||
.two { display:grid; grid-template-columns:1fr 1fr; gap:11px; }
|
||||
.three { display:grid; grid-template-columns:1fr 1fr 1fr; gap:9px; }
|
||||
input.f, select.f, textarea.f { width:100%; background:var(--field); color:var(--text); border:1px solid var(--field-border);
|
||||
border-radius:9px; padding:9px 11px; font-size:13.5px; font-family:inherit; outline:none; transition:border .15s,box-shadow .15s; }
|
||||
input.f:focus, select.f:focus, textarea.f:focus { border-color:var(--field-focus);
|
||||
box-shadow:0 0 0 3px color-mix(in srgb, var(--field-focus) 22%, transparent); }
|
||||
textarea.f { resize:vertical; min-height:56px; }
|
||||
.hint { font-size:11px; color:var(--faint); margin-top:5px; }
|
||||
.with-icon { position:relative; } .with-icon .pin { position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#5ba848; font-size:16px; }
|
||||
|
||||
.seg { display:inline-flex; background:var(--chip); border:1px solid var(--border); border-radius:9px; padding:3px; gap:3px; }
|
||||
.seg button { border:none; background:transparent; color:var(--muted); font-weight:600; font-size:12.5px; padding:6px 14px;
|
||||
border-radius:7px; cursor:pointer; font-family:inherit; }
|
||||
.seg button.on { background:var(--card); color:var(--accent); box-shadow:var(--shadow); }
|
||||
.seg.full { display:flex; } .seg.full button { flex:1; }
|
||||
|
||||
.timepick { display:inline-flex; align-items:stretch; gap:7px; }
|
||||
.timepick select.f { width:auto; padding-right:24px; }
|
||||
.ampm { display:inline-flex; background:var(--chip); border:1px solid var(--border); border-radius:9px; padding:3px; }
|
||||
.ampm button { border:none; background:transparent; color:var(--muted); font-weight:700; font-size:12px; padding:6px 12px; border-radius:7px; cursor:pointer; }
|
||||
.ampm button.on { background:var(--accent); color:#fff; }
|
||||
.endtime { font-size:13px; color:var(--muted); margin-top:7px; } .endtime b { color:var(--text); }
|
||||
.avail { display:inline-flex; align-items:center; gap:6px; font-size:11.5px; font-weight:600; color:var(--ok);
|
||||
background:color-mix(in srgb,var(--ok) 14%,transparent); padding:3px 9px; border-radius:20px; margin-top:6px; }
|
||||
|
||||
.opt { display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border); }
|
||||
.opt:last-child { border-bottom:none; }
|
||||
.opt .lab { font-size:13.5px; font-weight:500; } .opt .lab small { display:block; color:var(--faint); font-weight:400; font-size:11.5px; }
|
||||
.sw { width:42px; height:24px; border-radius:20px; background:var(--field-border); position:relative; cursor:pointer; transition:background .15s; flex-shrink:0; }
|
||||
.sw::after { content:''; position:absolute; width:18px; height:18px; border-radius:50%; background:#fff; top:3px; left:3px; transition:left .15s; box-shadow:0 1px 2px rgba(0,0,0,.3); }
|
||||
.sw.on { background:var(--ok); } .sw.on::after { left:21px; }
|
||||
|
||||
/* fee readout inside Service & Pricing */
|
||||
.feeline { display:flex; align-items:center; justify-content:space-between; background:var(--money-soft);
|
||||
border:1px solid color-mix(in srgb,var(--money) 35%,transparent); border-radius:10px; padding:11px 14px; margin-top:4px; }
|
||||
.feeline .lbl { font-size:12.5px; font-weight:600; color:var(--text); }
|
||||
.feeline .lbl small { display:block; color:var(--faint); font-weight:400; font-size:11px; }
|
||||
.feeline .amt { font-size:20px; font-weight:800; color:var(--money); }
|
||||
|
||||
/* ESTIMATE strip */
|
||||
.estimate { grid-column:1/-1; background:var(--money-soft); border:1px solid color-mix(in srgb,var(--money) 40%,transparent);
|
||||
border-left:5px solid var(--money); border-radius:13px; padding:15px 18px; display:flex; align-items:center; gap:20px; flex-wrap:wrap; }
|
||||
.estimate .breakdown { display:flex; gap:18px; flex-wrap:wrap; flex:1; }
|
||||
.estimate .bk { } .estimate .bk .k { font-size:10.5px; text-transform:uppercase; letter-spacing:.5px; color:var(--faint); }
|
||||
.estimate .bk .v { font-size:15px; font-weight:700; margin-top:1px; }
|
||||
.estimate .total { text-align:right; }
|
||||
.estimate .total .k { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--money); font-weight:700; }
|
||||
.estimate .total .v { font-size:27px; font-weight:800; color:var(--money); line-height:1; }
|
||||
.estimate .total .note { font-size:11px; color:var(--faint); margin-top:3px; }
|
||||
|
||||
.foot { display:flex; align-items:center; justify-content:flex-end; gap:11px; padding:16px 24px; background:var(--panel); border-top:1px solid var(--border); }
|
||||
.foot .spacer { margin-right:auto; font-size:12px; color:var(--faint); }
|
||||
.btn { border:none; border-radius:10px; padding:11px 18px; font-size:13.5px; font-weight:600; cursor:pointer; font-family:inherit; }
|
||||
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
|
||||
.btn.primary { color:#fff; background:linear-gradient(135deg,#5ba848,#2e7aad); box-shadow:0 3px 10px color-mix(in srgb,#2e7aad 40%,transparent); }
|
||||
.hide { display:none !important; }
|
||||
.note { max-width:1000px; margin:14px auto 40px; padding:0 18px; color:var(--muted); font-size:12.5px; }
|
||||
.note code { background:var(--chip); padding:1px 6px; border-radius:5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="dialog">
|
||||
<div class="topbar">
|
||||
<div><h1>Book a Service</h1><div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div></div>
|
||||
<button class="theme-btn" onclick="toggleTheme()">◐ Light / Dark</button>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<span class="step active">Scheduled</span><span class="step">En Route</span>
|
||||
<span class="step">In Progress</span><span class="step">Completed</span>
|
||||
<span class="step draft">● Draft repair SO will be created</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="grid">
|
||||
<!-- CUSTOMER -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Customer</h3>
|
||||
<div class="row">
|
||||
<div class="seg full">
|
||||
<button class="on" id="segExisting" onclick="custMode('existing')">Existing customer</button>
|
||||
<button id="segNew" onclick="custMode('new')">New client</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="custExisting">
|
||||
<div class="row">
|
||||
<label class="fl">Search by phone, name or SO</label>
|
||||
<input class="f" placeholder="e.g. (416) 555-0142 …" value="(416) 555-0142 — Margaret Chen">
|
||||
<div class="hint">Inbound call? Type the phone number — we match the contact & their history.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="custNew" class="hide">
|
||||
<div class="row two">
|
||||
<div><label class="fl">Client name *</label><input class="f" placeholder="Full name"></div>
|
||||
<div><label class="fl">Phone *</label><input class="f" placeholder="(416) 555-…"></div>
|
||||
</div>
|
||||
<div class="row"><label class="fl">Email</label><input class="f" type="email" placeholder="client@email.com"></div>
|
||||
<div class="row"><label class="fl">Address</label>
|
||||
<div class="with-icon"><input class="f" placeholder="Start typing an address…"><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row three">
|
||||
<div><label class="fl">Unit</label><input class="f" placeholder="#"></div>
|
||||
<div><label class="fl">Buzz</label><input class="f" placeholder="—"></div>
|
||||
<div><label class="fl">City</label><input class="f" placeholder="City"></div>
|
||||
</div>
|
||||
<div class="hint">Contact is created & linked on save — all from this page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE & PRICING -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
||||
<div class="row two">
|
||||
<div>
|
||||
<label class="fl">Device being serviced</label>
|
||||
<select class="f" id="device" onchange="onDevice()">
|
||||
<option value="standard">Mobility Scooter</option>
|
||||
<option value="standard">Powerchair</option>
|
||||
<option value="standard">Wheelchair</option>
|
||||
<option value="lift">Stairlift</option>
|
||||
<option value="lift">Patient / Ceiling Lift</option>
|
||||
<option value="standard">Lift Chair</option>
|
||||
<option value="standard">Hospital Bed</option>
|
||||
<option value="standard">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Issue / symptom</label>
|
||||
<input class="f" placeholder="e.g. won't power on">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="callTypeRow">
|
||||
<label class="fl">Service call type</label>
|
||||
<select class="f" id="callType" onchange="recalc()">
|
||||
<option data-fee="95" data-km="0">Standard Service Call — $95 (incl. 30 min labour)</option>
|
||||
<option data-fee="160" data-km="0">Lift & Elevating Service Call — $160 (incl. 30 min)</option>
|
||||
<option data-fee="120" data-km="1">Rush Service Call — $120 + $0.70/km ×2-way</option>
|
||||
<option data-fee="140" data-km="1">After-Hours Service Call — $140 + $0.70/km ×2-way</option>
|
||||
</select>
|
||||
<div class="hint">Auto-suggested from the device — change if needed.</div>
|
||||
</div>
|
||||
<div class="feeline" id="feeBox">
|
||||
<div class="lbl">Call-out fee<small id="feeSub">Standard · includes 30 min labour</small></div>
|
||||
<div class="amt" id="feeAmt">$95</div>
|
||||
</div>
|
||||
<div class="hint" id="inshopNote" style="display:none;">In-shop job — no call-out fee; labour billed at $75/hr.</div>
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULE -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Schedule</h3>
|
||||
<div class="row two">
|
||||
<div><label class="fl">Date</label><input class="f" type="date" value="2026-06-03"></div>
|
||||
<div><label class="fl">Duration</label>
|
||||
<select class="f" id="dur" onchange="recalc();endTime()">
|
||||
<option value="0.5">30 min</option><option value="1" selected>1 hour</option>
|
||||
<option value="1.5">1.5 hours</option><option value="2">2 hours</option><option value="3">3 hours</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="fl">Start time</label>
|
||||
<div class="timepick">
|
||||
<select class="f" id="hh" onchange="endTime()"><option>9</option><option>10</option><option>11</option><option>12</option><option>1</option><option>2</option><option>3</option><option>4</option></select>
|
||||
<select class="f" id="mm" onchange="endTime()"><option>:00</option><option>:15</option><option>:30</option><option>:45</option></select>
|
||||
<div class="ampm"><button class="on" onclick="ampm(this)">AM</button><button onclick="ampm(this)">PM</button></div>
|
||||
</div>
|
||||
<div class="endtime">Ends at <b id="endlbl">10:00 AM</b> · your local time</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="fl">Technician</label>
|
||||
<select class="f"><option>— Choose —</option><option selected>Dave Wilson</option><option>Priya Anand</option></select>
|
||||
<span class="avail">● 3 open slots before 5:00 PM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOCATION -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Location</h3>
|
||||
<div class="opt" style="border:none; padding-top:0;">
|
||||
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $75/hr</small></div>
|
||||
<div class="sw" id="inshopSw" onclick="toggleShop(this)"></div>
|
||||
</div>
|
||||
<div id="addrBlock">
|
||||
<div class="row"><label class="fl">Job address</label>
|
||||
<div class="with-icon"><input class="f" placeholder="Auto-fills from customer…" value="88 Bloor St E, Toronto"><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row two">
|
||||
<div><label class="fl">Unit / Suite</label><input class="f" placeholder="#"></div>
|
||||
<div><label class="fl">Buzz code</label><input class="f" placeholder="—"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JOB DETAILS -->
|
||||
<div class="card span2">
|
||||
<h3><span class="dot"></span>Job details</h3>
|
||||
<div class="two">
|
||||
<div class="row"><label class="fl">Work description</label><textarea class="f" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||
</div>
|
||||
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" onclick="sw(this)"></div></div>
|
||||
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" onclick="sw(this)"></div></div>
|
||||
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw on" onclick="sw(this)"></div></div>
|
||||
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw on" onclick="sw(this)"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ESTIMATE -->
|
||||
<div class="estimate">
|
||||
<div class="breakdown">
|
||||
<div class="bk"><div class="k">Call-out</div><div class="v" id="eCall">$95</div></div>
|
||||
<div class="bk"><div class="k">Est. labour</div><div class="v" id="eLab">$85 · 1h</div></div>
|
||||
<div class="bk" id="eKmBox" style="display:none;"><div class="k">Travel ($0.70/km ×2)</div><div class="v" id="eKm">$18</div></div>
|
||||
</div>
|
||||
<div class="total"><div class="k">Estimated total</div><div class="v" id="eTotal">$180</div>
|
||||
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="spacer">Local time · America/Toronto · 13 km away</span>
|
||||
<button class="btn ghost">Cancel</button>
|
||||
<button class="btn primary">Book & Create SO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
Mockup v2 — demo-wired (theme, customer mode, device→call-type, in-shop, AM/PM, switches, live estimate).
|
||||
Real build = an OWL client action; <b>Book & Create SO</b> calls one server method that find-or-creates the
|
||||
contact, creates the <code>fusion.technician.task</code> + a draft <code>sale.order</code> with the call-out line
|
||||
(+ auto per-km for rush/after-hours, from the computed distance). Rate-card items are seeded as service products.
|
||||
Toggle <b>◐</b> top-right for dark/light.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DIST_2WAY = 26, KM_RATE = 0.70; // demo: 13km away, 2-way
|
||||
let inshop=false, ap='AM';
|
||||
function toggleTheme(){ const h=document.documentElement; h.dataset.theme=h.dataset.theme==='dark'?'light':'dark'; }
|
||||
function custMode(m){ const ex=m==='existing';
|
||||
segExisting.classList.toggle('on',ex); segNew.classList.toggle('on',!ex);
|
||||
custExisting.classList.toggle('hide',!ex); custNew.classList.toggle('hide',ex); }
|
||||
function onDevice(){ const cat=device.value; callType.selectedIndex = cat==='lift'?1:0; recalc(); }
|
||||
function ampm(el){ [...el.parentNode.children].forEach(b=>b.classList.remove('on')); el.classList.add('on'); ap=el.textContent; endTime(); }
|
||||
function sw(el){ el.classList.toggle('on'); }
|
||||
function toggleShop(el){ el.classList.toggle('on'); inshop=el.classList.contains('on');
|
||||
addrBlock.classList.toggle('hide',inshop); callTypeRow.classList.toggle('hide',inshop);
|
||||
feeBox.classList.toggle('hide',inshop); inshopNote.style.display=inshop?'block':'none'; recalc(); }
|
||||
function endTime(){ const h=+hh.value, m=+mm.value.replace(':',''), dur=+document.getElementById('dur').value;
|
||||
let mins=((h%12)+(ap==='PM'?12:0))*60+m+dur*60;
|
||||
let eh=Math.floor(mins/60)%24, em=mins%60; endlbl.textContent=(eh%12||12)+':'+String(em).padStart(2,'0')+' '+(eh>=12?'PM':'AM'); }
|
||||
function money(n){ return '$'+n.toFixed(n%1?2:0); }
|
||||
function recalc(){
|
||||
const dur=+document.getElementById('dur').value;
|
||||
const labRate = inshop?75:85;
|
||||
let callout=0, km=0, sub='', kmFlag=false;
|
||||
if(!inshop){ const o=callType.options[callType.selectedIndex];
|
||||
callout=+o.dataset.fee; kmFlag=o.dataset.km==='1';
|
||||
feeAmt.textContent=money(callout); feeSub.textContent=o.text.split('—')[0].trim()+(kmFlag?' · + travel':' · incl. 30 min labour');
|
||||
if(kmFlag) km=DIST_2WAY*KM_RATE;
|
||||
}
|
||||
// labour: first 30 min included on standard/lift call (not rush/afterhours which are time-based but keep simple)
|
||||
const incl = (!inshop && !kmFlag) ? 0.5 : 0;
|
||||
const billLabHrs = Math.max(0, dur - incl);
|
||||
const lab = billLabHrs*labRate;
|
||||
eCall.textContent = inshop?'—':money(callout);
|
||||
eLab.textContent = money(lab)+' · '+billLabHrs+'h @ $'+labRate;
|
||||
eKmBox.style.display = kmFlag?'block':'none'; eKm.textContent=money(km);
|
||||
eTotal.textContent = money(callout+lab+km);
|
||||
}
|
||||
endTime(); recalc();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,298 @@
|
||||
# NexaCloud→Odoo Cutover — Plan 01: Odoo subscription-cancel endpoint
|
||||
|
||||
> **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:** Add the one inbound endpoint NexaCloud's deprovision path needs — cancel (close) a subscription — to `fusion_centralize_billing`, with the same auth model the other endpoints already use.
|
||||
|
||||
**Architecture:** New `fusion.billing.service._api_cancel_subscription(external_ref)` resolves the subscription via the existing `_fc_resolve_subscription`, enforces the same "partner must be linked to this service" authorization as `_api_record_usage`, and closes it with Odoo 19's native `set_close()` (→ `subscription_state='6_churn'`). A `DELETE /api/billing/v1/subscriptions/<ref>` route wraps it.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise (`sale_subscription`), Python, Odoo `TransactionCase` tests.
|
||||
|
||||
**Spec:** [`2026-06-02-nexacloud-odoo-billing-cutover-design.md`](../specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md) §4.1.3
|
||||
|
||||
---
|
||||
|
||||
## ⚠ Test harness (supersedes any `-d nexamain` command below)
|
||||
|
||||
**NEVER run `-u` / `--test-enable` against the live `nexamain` DB.** Tests run in an **isolated throwaway container** against a dedicated DB, reading a **separate** addons copy so the live module is never touched:
|
||||
|
||||
```
|
||||
# 1) edit files on branch feat/nexacloud-odoo-billing-cutover, then sync the changed
|
||||
# module files to the staging addons copy on odoo-nexa:
|
||||
# /opt/odoo/custom-addons-staging/fusion_centralize_billing/...
|
||||
# 2) run (ssh odoo-nexa):
|
||||
docker run --rm --network odoo_odoo-network \
|
||||
-v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro \
|
||||
-v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \
|
||||
-v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro \
|
||||
-v /opt/odoo/staging-data:/var/lib/odoo \
|
||||
odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test \
|
||||
--db_host=db --db_user=odoo \
|
||||
--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \
|
||||
--test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel \
|
||||
-u fusion_centralize_billing --stop-after-init --no-http
|
||||
```
|
||||
- `fcb_test` is a **fresh** install DB (not a prod clone). `nexamain_staging` is a prod clone kept for later integration/importer plans.
|
||||
- **Scope each step's run to the relevant test class** (`:TestSubscriptionCancel`, `:TestSubscriptionCancelHttp`). The wider suite is **not hermetic yet** (see Plan 00) — `test_invoice_ledger` needs a configured Canadian CoA/active CAD/HST; `test_usage`/`test_webhook` collide with cloned prod data. Don't gate this plan on those.
|
||||
- The per-step `Run:` blocks below that mention `-d nexamain` are **illustrative only — use this harness instead.**
|
||||
|
||||
> **Prerequisite — Plan 00 (make the suite hermetic):** before green-baseline TDD, fix fixtures so the whole suite passes on `fcb_test`: `setUp` should get-or-create the `nexacloud`/`cpu_seconds` records (idempotent), and a test-setup helper must ensure an active CAD currency + a Canadian CoA + a 13% HST sale tax. Tracked as its own plan; recommended before Plan 01 execution.
|
||||
|
||||
---
|
||||
|
||||
## Increment plan sequence (this is Plan 01 of 6)
|
||||
|
||||
Each is its own plan doc + its own working, testable deliverable. Order reflects dependencies:
|
||||
|
||||
1. **Odoo: subscription-cancel endpoint** ← *this doc* (unblocked; no external decisions).
|
||||
2. **Odoo: NexaCloud charge catalog** — products + `sale.subscription.plan` (`NC-PLAN-*`) + `fusion.billing.charge` (cpu_seconds quota/overage). **Blocked on confirming real NexaCloud plan pricing/quotas** (open review Q#1) before it can be written placeholder-free.
|
||||
3. **Odoo: importer go-forward subscriptions** — extend `wizards/import_wizard.py` to create one shadow `sale.order` per active deployment with go-forward `next_invoice_date`; the safety test that asserts **no past-period invoice** is the centrepiece (guards against the 2026-05-27 Lago re-bill).
|
||||
4. **NexaCloud: adapter activation** — config (`odoo_billing_base_url`/`api_key`/staged enable), customer + subscription create/cancel calls, reconciliation-amount push.
|
||||
5. **NexaCloud: control-loop receiver** — activate `/billing/webhooks/central` HMAC verify → suspend/restore/deprovision via `network_isolation`/`throttle_checker`/`resource_manager`.
|
||||
6. **Dual-run + gated flip** — operational runbook: shadow ≥1 cycle, reconcile to cent, then the reversible flip flag.
|
||||
|
||||
---
|
||||
|
||||
## File structure (this plan)
|
||||
|
||||
- Modify: `fusion_centralize_billing/models/service.py` — add `_api_cancel_subscription`.
|
||||
- Modify: `fusion_centralize_billing/controllers/api.py` — add `DELETE /subscriptions/<ref>`.
|
||||
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py` — service-method + authorization tests.
|
||||
- Modify: `fusion_centralize_billing/tests/__init__.py` — import the new test module.
|
||||
|
||||
Run tests (from `K:\Github\CLAUDE.md` workflow, adapted to odoo-nexa):
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `_api_cancel_subscription` service method
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/models/service.py` (add method after `_api_create_subscription`, ~line 250)
|
||||
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py`
|
||||
- Modify: `fusion_centralize_billing/tests/__init__.py`
|
||||
|
||||
- [ ] **Step 0: Verify the Odoo 19 close method (do NOT code from memory — per `K:\Github\CLAUDE.md`)**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app grep -nE 'def set_close|def set_open|6_churn' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head"
|
||||
```
|
||||
Expected: a `def set_close(self...)` exists and sets `subscription_state='6_churn'`. If the method name differs in this build, use the actual name in Step 3 and the assertion in Step 1.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `fusion_centralize_billing/tests/test_subscription_cancel.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSubscriptionCancel(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.product = self.env['product.product'].sudo().create(
|
||||
{'name': 'NexaCloud Plan', 'type': 'service',
|
||||
'recurring_invoice': True, 'list_price': 49.0})
|
||||
self.svc_a = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
self.svc_b = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'Other', 'code': 'other'})
|
||||
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
|
||||
res = self.svc_a._api_create_subscription({
|
||||
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
|
||||
self.sub = self.env['sale.order'].browse(res['subscription_id'])
|
||||
|
||||
def test_cancel_closes_subscription(self):
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_is_idempotent(self):
|
||||
self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_unknown_subscription_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('999999999')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
|
||||
def test_cancel_cross_service_rejected(self):
|
||||
# svc_b is not linked to the customer that owns self.sub
|
||||
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
|
||||
def test_cancel_missing_id_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
```
|
||||
|
||||
Append to `fusion_centralize_billing/tests/__init__.py`:
|
||||
```python
|
||||
from . import test_subscription_cancel
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: FAIL — `AttributeError: 'fusion.billing.service' object has no attribute '_api_cancel_subscription'`.
|
||||
|
||||
- [ ] **Step 3: Implement the method**
|
||||
|
||||
In `fusion_centralize_billing/models/service.py`, add immediately after `_api_create_subscription`:
|
||||
```python
|
||||
def _api_cancel_subscription(self, external_ref):
|
||||
"""Cancel (close) the subscription identified by ``external_ref``.
|
||||
|
||||
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
|
||||
exist, be a subscription, and belong to a customer THIS service is linked
|
||||
to. Idempotent — closing an already-churned subscription returns ok.
|
||||
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if external_ref in (None, ''):
|
||||
return {'status': 'error', 'error': 'subscription id required'}
|
||||
sub = self._fc_resolve_subscription(external_ref)
|
||||
linked_partners = self.account_link_ids.mapped('partner_id')
|
||||
if not sub.exists() or not sub.is_subscription \
|
||||
or sub.partner_id not in linked_partners:
|
||||
return {'status': 'error', 'error': 'unknown subscription'}
|
||||
if sub.subscription_state != '6_churn':
|
||||
sub.set_close()
|
||||
return {'status': 'ok', 'subscription_id': sub.id,
|
||||
'subscription_state': sub.subscription_state}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: PASS — 5 tests, 0 failures. (If `set_close()` was a different name in Step 0, use that name here and re-run.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_subscription_cancel.py fusion_centralize_billing/tests/__init__.py
|
||||
git commit -m "feat(billing): add _api_cancel_subscription (close sub, service-scoped authz)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `DELETE /subscriptions/<ref>` route
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/controllers/api.py` (add route after `post_subscription`, ~line 95)
|
||||
- Modify: `fusion_centralize_billing/tests/test_subscription_cancel.py` (add an HTTP-layer test)
|
||||
|
||||
- [ ] **Step 1: Write the failing test (HTTP layer)**
|
||||
|
||||
Append to `tests/test_subscription_cancel.py` a class that exercises the route through Odoo's test client. Add the import at the top of the file:
|
||||
```python
|
||||
from odoo.tests import HttpCase
|
||||
```
|
||||
Then append:
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSubscriptionCancelHttp(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.product = self.env['product.product'].sudo().create(
|
||||
{'name': 'NexaCloud Plan', 'type': 'service',
|
||||
'recurring_invoice': True, 'list_price': 49.0})
|
||||
self.svc = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
self.raw_key = self.svc.action_generate_api_key()
|
||||
self.svc._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
|
||||
res = self.svc._api_create_subscription({
|
||||
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
|
||||
self.sub_id = res['subscription_id']
|
||||
self.env.cr.commit()
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
self.env['sale.order'].browse(self.sub_id).sudo().unlink()
|
||||
|
||||
def test_delete_requires_auth(self):
|
||||
resp = self.url_open(
|
||||
"/api/billing/v1/subscriptions/%s" % self.sub_id,
|
||||
method='DELETE')
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
def test_delete_cancels_with_valid_key(self):
|
||||
resp = self.url_open(
|
||||
"/api/billing/v1/subscriptions/%s" % self.sub_id,
|
||||
method='DELETE',
|
||||
headers={'Authorization': 'Bearer %s' % self.raw_key})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()['subscription_state'], '6_churn')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: FAIL — the DELETE route returns 404 (route not registered) so the assertions fail.
|
||||
|
||||
- [ ] **Step 3: Implement the route**
|
||||
|
||||
In `fusion_centralize_billing/controllers/api.py`, add after `post_subscription`:
|
||||
```python
|
||||
@http.route(f"{API_BASE}/subscriptions/<sub_ref>", type="http", auth="none",
|
||||
methods=["DELETE"], csrf=False)
|
||||
def delete_subscription(self, sub_ref, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
result = service._api_cancel_subscription(sub_ref)
|
||||
if result.get("status") == "error":
|
||||
status = 404 if result.get("error") == "unknown subscription" else 400
|
||||
return self._json(result, status=status)
|
||||
return self._json(result)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: PASS — 2 tests, 0 failures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/controllers/api.py fusion_centralize_billing/tests/test_subscription_cancel.py
|
||||
git commit -m "feat(billing): DELETE /api/billing/v1/subscriptions/<ref> cancel route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** §4.1.3 "add subscription cancel (`DELETE /subscriptions/:id`)" → Tasks 1+2. ✔
|
||||
- **Placeholder scan:** none — all code is concrete; Step 0 verifies the one Odoo-internal name (`set_close`) against the live container instead of assuming.
|
||||
- **Type consistency:** `_api_cancel_subscription` returns the same `{'status','subscription_id','subscription_state'}` shape as `_api_create_subscription`; error shape matches `_api_record_usage` (`{'status':'error','error':...}`); resolver reused (`_fc_resolve_subscription`) so cross-service rejection is identical to `/usage`. ✔
|
||||
- **Authorization parity:** cancel uses the exact `not sub.exists() or not sub.is_subscription or sub.partner_id not in linked_partners` guard as `_api_record_usage`. ✔
|
||||
737
docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md
Normal file
737
docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md
Normal file
@@ -0,0 +1,737 @@
|
||||
# Service Booking Wizard + Auto-Quote — Implementation Plan (Plan 2 of 2)
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** A polished OWL "Book a Service" wizard that captures the client (incl. new clients inline), books the technician task, prices the call-out from the Plan-1 rate table, and auto-creates a draft repair Sale Order — with a correct, consistent timezone conversion.
|
||||
|
||||
**Architecture:** TZ fix in `fusion_tasks`; everything else in `fusion_claims` (it owns the SO + the `technician.task` SO-link + Plan 1's rates). A server method `action_book_from_wizard` does the work (contact + task + SO); an OWL client action is the UI and calls it through two `jsonrpc` controller routes. Pricing is read from `fusion.service.rate` (Plan 1) — never hardcoded.
|
||||
|
||||
**Tech Stack:** Odoo 19 (ORM, `TransactionCase`), OWL (`@odoo/owl`, standalone `rpc` from `@web/core/network/rpc`, `registry.category("actions")`), SCSS branching on `$o-webclient-color-scheme`.
|
||||
|
||||
**Depends on:** Plan 1 (`fusion.service.rate` + `get_callout`/`get_rate`). **Spec:** `…/specs/2026-06-03-technician-service-booking-design.md`. **Mockup (UI source of truth):** `…/mockups/technician-booking-wizard.html`.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Testing reality
|
||||
|
||||
`fusion_claims` is Enterprise-only → not installable on local Community. `TransactionCase` tests run on a **Westin Enterprise clone** (see Plan 1's testing note + repo `CLAUDE.md`). OWL UI has **no unit test** — verify by manual smoke on the clone browser. Pure-Python tasks (1–4) are TDD; the OWL task (5) is build-then-smoke.
|
||||
|
||||
**Pre-flight (rule #1 — never code from memory):** before Tasks 1, 3, 4, read the real signatures:
|
||||
```bash
|
||||
docker exec odoo-dev-app sed -n '760,800p;975,1010p;2725,2775p' \
|
||||
/mnt/extra-addons/fusion_tasks/models/technician_task.py
|
||||
```
|
||||
Confirm `_get_local_tz`, `_compute_datetimes`/inverses, `_calculate_travel_time(origin_lat, origin_lng)` (sets `travel_distance_km`), and `_quick_travel_time`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `fusion_tasks/models/technician_task.py` *(modify ~781-798)* | tz-consistent inverses |
|
||||
| `fusion_tasks/tests/test_task_tz.py` + `__init__.py` *(create)* | tz round-trip test |
|
||||
| `fusion_claims/models/technician_task.py` *(modify)* | relax `_check_order_link`; add `x_fc_service_call_type`; pricing resolver; SO builder; `action_book_from_wizard` |
|
||||
| `fusion_claims/models/sale_order.py` *(modify)* | `x_fc_is_service_repair` flag |
|
||||
| `fusion_claims/data/service_repair_data.xml` *(create)* | "Service Repair" CRM tag |
|
||||
| `fusion_claims/controllers/__init__.py` + `controllers/service_booking.py` *(create)* | `jsonrpc` refdata + submit routes |
|
||||
| `fusion_claims/__init__.py` *(modify)* | import controllers |
|
||||
| `fusion_claims/static/src/js/service_booking/service_booking.js` *(create)* | OWL client action |
|
||||
| `fusion_claims/static/src/xml/service_booking.xml` *(create)* | OWL template (ported from mockup) |
|
||||
| `fusion_claims/static/src/scss/_service_booking_tokens.scss` + `service_booking.scss` *(create)* | styles, dark/light |
|
||||
| `fusion_claims/views/service_booking_action.xml` *(create)* | `ir.actions.client` + menu |
|
||||
| `fusion_claims/__manifest__.py` *(modify)* | assets + data + version |
|
||||
| `fusion_claims/tests/test_service_booking.py` *(create)* | resolver, SO builder, booking method |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Timezone-consistent inverses (`fusion_tasks`)
|
||||
|
||||
**Files:** Modify `fusion_tasks/models/technician_task.py`; create `fusion_tasks/tests/test_task_tz.py` (+ `tests/__init__.py` if absent).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `fusion_tasks/tests/test_task_tz.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestTaskTz(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env.user.tz = 'America/Toronto' # UTC-4 in summer
|
||||
cls.task = cls.env['fusion.technician.task'].create({
|
||||
'scheduled_date': date(2026, 6, 3),
|
||||
'time_start': 9.0, 'time_end': 10.0,
|
||||
})
|
||||
|
||||
def test_local_to_utc_compute(self):
|
||||
# 9:00 local Toronto (DST, -4) -> 13:00 UTC stored
|
||||
self.assertEqual(self.task.datetime_start.hour, 13)
|
||||
|
||||
def test_inverse_round_trips_with_same_tz(self):
|
||||
# writing datetime_start back must recover the same local time_start
|
||||
self.task.datetime_start = self.task.datetime_start # force inverse
|
||||
self.task.flush_recordset(['datetime_start'])
|
||||
self.assertAlmostEqual(self.task.time_start, 9.0, places=2)
|
||||
```
|
||||
|
||||
Register in `fusion_tasks/tests/__init__.py` (create if missing):
|
||||
|
||||
```python
|
||||
from . import test_task_tz
|
||||
```
|
||||
|
||||
If `fusion_tasks/tests/` doesn't exist, also add `'fusion_tasks/tests'` is auto-discovered — just ensure the `__init__.py` exists.
|
||||
|
||||
- [ ] **Step 2: Run — verify it fails** (on the clone, `--test-tags /fusion_tasks.TestTaskTz`). Expected: `test_inverse_round_trips` FAILS if user.tz ≠ company-calendar tz, or passes spuriously if they're equal — set the company `resource_calendar_id.tz` to `America/Toronto` in `setUpClass` too if needed to expose the mismatch.
|
||||
|
||||
- [ ] **Step 3: Fix the inverses**
|
||||
|
||||
In `fusion_tasks/models/technician_task.py`, the two inverse methods currently use `pytz.timezone(self.env.user.tz or 'UTC')`. Change **both** to use the same resolver as `_compute_datetimes`:
|
||||
|
||||
```python
|
||||
def _inverse_datetime_start(self):
|
||||
"""When datetime_start changes (calendar drag), update date + time. Uses the
|
||||
SAME tz resolver as _compute_datetimes so the round-trip is consistent."""
|
||||
import pytz
|
||||
user_tz = self._get_local_tz()
|
||||
for task in self:
|
||||
if task.datetime_start:
|
||||
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
|
||||
task.scheduled_date = local_dt.date()
|
||||
task.time_start = local_dt.hour + local_dt.minute / 60.0
|
||||
|
||||
def _inverse_datetime_end(self):
|
||||
import pytz
|
||||
user_tz = self._get_local_tz()
|
||||
for task in self:
|
||||
if task.datetime_end:
|
||||
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)
|
||||
task.time_end = local_dt.hour + local_dt.minute / 60.0
|
||||
```
|
||||
|
||||
(Only the `user_tz = …` line changes in each — from `pytz.timezone(self.env.user.tz or 'UTC')` to `self._get_local_tz()`.)
|
||||
|
||||
- [ ] **Step 4: Run — verify it passes** (`--test-tags /fusion_tasks.TestTaskTz`). Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_tasks/models/technician_task.py fusion_tasks/tests/test_task_tz.py fusion_tasks/tests/__init__.py
|
||||
git commit -m "fix(fusion_tasks): make datetime inverses use the same tz resolver as compute"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Relax SO constraint + repair-SO identity (`fusion_claims`)
|
||||
|
||||
**Files:** Modify `fusion_claims/models/technician_task.py`, `fusion_claims/models/sale_order.py`; create `fusion_claims/data/service_repair_data.xml`; modify `__manifest__.py`; test in `fusion_claims/tests/test_service_booking.py`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `fusion_claims/tests/test_service_booking.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestServiceBooking(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Task = cls.env['fusion.technician.task']
|
||||
|
||||
def test_task_without_order_is_allowed(self):
|
||||
# repair for a brand-new client: no SO/PO must NOT raise
|
||||
t = self.Task.create({'task_type': 'repair', 'scheduled_date': date(2026, 6, 3)})
|
||||
self.assertTrue(t.id)
|
||||
|
||||
def test_sale_order_has_service_repair_flag(self):
|
||||
so = self.env['sale.order'].new({})
|
||||
self.assertIn('x_fc_is_service_repair', so._fields)
|
||||
```
|
||||
|
||||
Register in `fusion_claims/tests/__init__.py` (append): `from . import test_service_booking`.
|
||||
|
||||
- [ ] **Step 2: Run — verify it fails** (`--test-tags /fusion_claims.TestServiceBooking`). Expected: `test_task_without_order_is_allowed` FAILS with the ValidationError from `_check_order_link`; `test_sale_order_has_service_repair_flag` FAILS (field missing).
|
||||
|
||||
- [ ] **Step 3: Relax the constraint**
|
||||
|
||||
In `fusion_claims/models/technician_task.py`, replace the body of `_check_order_link` so it no longer requires an order (the wizard auto-creates one; in-shop/walk-in legitimately have none):
|
||||
|
||||
```python
|
||||
@api.constrains('sale_order_id', 'purchase_order_id')
|
||||
def _check_order_link(self):
|
||||
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
|
||||
# walk-in tasks may have none. No order link is required anymore.
|
||||
return
|
||||
```
|
||||
|
||||
(Keep the method as a no-op rather than deleting it, so any external `super()`/override chains stay intact.)
|
||||
|
||||
- [ ] **Step 4: Add the repair flag + tag**
|
||||
|
||||
In `fusion_claims/models/sale_order.py`, add to the `sale.order` class:
|
||||
|
||||
```python
|
||||
x_fc_is_service_repair = fields.Boolean(
|
||||
string='Service Repair', copy=False,
|
||||
help='Auto-created from the technician service booking wizard.',
|
||||
)
|
||||
```
|
||||
|
||||
Create `fusion_claims/data/service_repair_data.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="tag_service_repair" model="crm.tag">
|
||||
<field name="name">Service Repair</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
Register it in `__manifest__.py` `data` (after the service-rate data from Plan 1):
|
||||
|
||||
```python
|
||||
'data/service_repair_data.xml',
|
||||
```
|
||||
|
||||
> `crm.tag` requires the `sale_crm`/`crm` dependency. If `fusion_claims` doesn't pull `crm`, use `sale.order.tag` — verify which tag model exists: `docker exec odoo-dev-app odoo shell -d westin-v19-ratetest -c "print('crm.tag' in env, 'sale.order' in env)"`. Default to `crm.tag` (Westin has CRM); fall back to skipping the tag and relying on the boolean flag if neither is clean.
|
||||
|
||||
- [ ] **Step 5: Run — verify it passes.** Expected: both tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/models/technician_task.py fusion_claims/models/sale_order.py \
|
||||
fusion_claims/data/service_repair_data.xml fusion_claims/__manifest__.py \
|
||||
fusion_claims/tests/test_service_booking.py fusion_claims/tests/__init__.py
|
||||
git commit -m "feat(fusion_claims): allow order-less tasks + service-repair SO flag/tag"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `x_fc_service_call_type` + pricing resolver + SO builder (`fusion_claims`)
|
||||
|
||||
**Files:** Modify `fusion_claims/models/technician_task.py`; test in `test_service_booking.py`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `TestServiceBooking`):
|
||||
|
||||
```python
|
||||
def test_resolve_service_lines_standard_rush(self):
|
||||
Task = self.Task
|
||||
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
|
||||
# call-out $120 + per-km line qty 20 @ $0.70
|
||||
callout = [l for l in lines if l['price_unit'] == 120.0]
|
||||
per_km = [l for l in lines if l['name_is_km']]
|
||||
self.assertTrue(callout)
|
||||
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
|
||||
self.assertEqual(per_km[0]['price_unit'], 0.70)
|
||||
|
||||
def test_resolve_service_lines_in_shop_empty_callout(self):
|
||||
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
|
||||
self.assertEqual(lines, [])
|
||||
|
||||
def test_build_service_so(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
|
||||
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
|
||||
self.assertEqual(so.state, 'draft')
|
||||
self.assertTrue(so.x_fc_is_service_repair)
|
||||
self.assertEqual(so.partner_id, partner)
|
||||
self.assertEqual(so.order_line[0].price_unit, 95.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — verify it fails** (methods undefined).
|
||||
|
||||
- [ ] **Step 3: Add the field + resolver + builder**
|
||||
|
||||
In `fusion_claims/models/technician_task.py`, add the field to the class:
|
||||
|
||||
```python
|
||||
x_fc_service_call_type = fields.Char(
|
||||
string='Service Call Type',
|
||||
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
|
||||
)
|
||||
```
|
||||
|
||||
Add these methods (model methods; rely on Plan 1's `fusion.service.rate`):
|
||||
|
||||
```python
|
||||
@api.model
|
||||
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
|
||||
"""Return a list of sale.order.line vals dicts for a service booking,
|
||||
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
|
||||
Rate = self.env['fusion.service.rate']
|
||||
lines = []
|
||||
callout = Rate.get_callout(category, timing, in_shop=in_shop)
|
||||
if not callout:
|
||||
return lines
|
||||
lines.append({
|
||||
'product_id': callout.product_id.id,
|
||||
'name': callout.name,
|
||||
'product_uom_qty': 1.0,
|
||||
'price_unit': callout.price,
|
||||
'name_is_km': False,
|
||||
})
|
||||
if callout.adds_per_km and distance_km:
|
||||
per_km = Rate.get_rate('per_km')
|
||||
if per_km:
|
||||
lines.append({
|
||||
'product_id': per_km.product_id.id,
|
||||
'name': '%s — %.1f km × 2-way' % (per_km.name, distance_km),
|
||||
'product_uom_qty': round(distance_km * 2.0, 1),
|
||||
'price_unit': per_km.price,
|
||||
'name_is_km': True,
|
||||
})
|
||||
return lines
|
||||
|
||||
@api.model
|
||||
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
|
||||
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines."""
|
||||
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
|
||||
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
|
||||
so_vals = {
|
||||
'partner_id': partner.id,
|
||||
'x_fc_is_service_repair': True,
|
||||
'order_line': order_lines,
|
||||
}
|
||||
tag = self.env.ref('fusion_claims.tag_service_repair', raise_if_not_found=False)
|
||||
if tag and 'tag_ids' in self.env['sale.order']._fields:
|
||||
so_vals['tag_ids'] = [(4, tag.id)]
|
||||
return self.env['sale.order'].create(so_vals)
|
||||
```
|
||||
|
||||
> The `name_is_km` key is a test-only marker stripped before create. If `sale.order` has no `tag_ids` (no CRM), the guard skips the tag.
|
||||
|
||||
- [ ] **Step 4: Run — verify it passes.**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/models/technician_task.py fusion_claims/tests/test_service_booking.py
|
||||
git commit -m "feat(fusion_claims): service pricing resolver + draft-SO builder from rate table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `action_book_from_wizard` + controller routes (`fusion_claims`)
|
||||
|
||||
**Files:** Modify `fusion_claims/models/technician_task.py`; create `fusion_claims/controllers/__init__.py`, `controllers/service_booking.py`; modify `fusion_claims/__init__.py`; test in `test_service_booking.py`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append):
|
||||
|
||||
```python
|
||||
def test_action_book_creates_contact_task_and_so(self):
|
||||
payload = {
|
||||
'cust_mode': 'new',
|
||||
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
|
||||
'street': '88 Bloor St E', 'city': 'Toronto'},
|
||||
'category': 'standard', 'timing': 'normal', 'in_shop': False,
|
||||
'device': 'scooter', 'issue': "won't power on",
|
||||
'date': '2026-06-03', 'time_start': 9.0, 'duration_hr': 1.0,
|
||||
'technician_id': False, 'description': 'check battery',
|
||||
}
|
||||
res = self.Task.action_book_from_wizard(payload)
|
||||
self.assertTrue(res['task_id'] and res['order_id'])
|
||||
task = self.Task.browse(res['task_id'])
|
||||
self.assertEqual(task.sale_order_id.id, res['order_id'])
|
||||
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
|
||||
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
|
||||
self.assertTrue(partner)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — verify it fails.**
|
||||
|
||||
- [ ] **Step 3: Implement `action_book_from_wizard`**
|
||||
|
||||
Add to `fusion_claims/models/technician_task.py` (read the travel method first — pre-flight). Distance: create the task, run its travel calc to populate `travel_distance_km`, read it for the per-km line, then attach the SO:
|
||||
|
||||
```python
|
||||
@api.model
|
||||
def action_book_from_wizard(self, payload):
|
||||
"""Single entry point for the OWL booking wizard:
|
||||
resolve/create contact -> create task -> compute distance -> build SO -> link."""
|
||||
Partner = self.env['res.partner']
|
||||
# 1. contact
|
||||
cust = payload.get('customer') or {}
|
||||
if payload.get('cust_mode') == 'new':
|
||||
partner = False
|
||||
email = (cust.get('email') or '').strip()
|
||||
phone = (cust.get('phone') or '').strip()
|
||||
if email:
|
||||
partner = Partner.search([('email', '=ilike', email)], limit=1)
|
||||
if not partner and phone:
|
||||
partner = Partner.search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = Partner.create({
|
||||
'name': cust.get('name') or 'Walk-in',
|
||||
'phone': phone or False, 'email': email or False,
|
||||
'street': cust.get('street') or False, 'city': cust.get('city') or False,
|
||||
})
|
||||
else:
|
||||
partner = Partner.browse(int(payload.get('partner_id'))) if payload.get('partner_id') else Partner
|
||||
|
||||
category = payload.get('category', 'standard')
|
||||
timing = payload.get('timing', 'normal')
|
||||
in_shop = bool(payload.get('in_shop'))
|
||||
|
||||
# 2. task
|
||||
dur = float(payload.get('duration_hr') or 1.0)
|
||||
t_start = float(payload.get('time_start') or 9.0)
|
||||
task_vals = {
|
||||
'task_type': 'repair',
|
||||
'scheduled_date': payload.get('date'),
|
||||
'time_start': t_start, 'time_end': t_start + dur, 'duration_hours': dur,
|
||||
'in_store': in_shop,
|
||||
'x_fc_service_call_type': '%s_%s' % (category, timing),
|
||||
'description': payload.get('description') or payload.get('issue') or '',
|
||||
}
|
||||
if payload.get('technician_id'):
|
||||
task_vals['technician_id'] = int(payload['technician_id'])
|
||||
if partner:
|
||||
task_vals['client_name'] = partner.name
|
||||
task_vals['client_phone'] = partner.phone or False
|
||||
task = self.create(task_vals)
|
||||
|
||||
# 3. distance (km) for per-km, if the rate adds it and the job has a location
|
||||
distance_km = 0.0
|
||||
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
|
||||
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
|
||||
try:
|
||||
task._calculate_travel_time(task.address_lat, task.address_lng) # sets travel_distance_km
|
||||
distance_km = task.travel_distance_km or 0.0
|
||||
except Exception:
|
||||
distance_km = 0.0
|
||||
|
||||
# 4. SO + link
|
||||
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
|
||||
if order:
|
||||
task.sale_order_id = order.id
|
||||
return {'task_id': task.id, 'order_id': order.id if order else False}
|
||||
```
|
||||
|
||||
> Verify field names against the model during the pre-flight read: `in_store` vs `in_shop`, `client_name`/`client_phone`, `address_lat`/`address_lng`, `technician_id`. Adjust the vals keys to the real field names (the screenshot shows In-Store, Client Name/Phone, Task Address). If `_calculate_travel_time` needs a different origin, pass the shop/technician start coords instead.
|
||||
|
||||
- [ ] **Step 4: Create the controller**
|
||||
|
||||
Create `fusion_claims/controllers/__init__.py`:
|
||||
|
||||
```python
|
||||
from . import service_booking
|
||||
```
|
||||
|
||||
Create `fusion_claims/controllers/service_booking.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class ServiceBookingController(http.Controller):
|
||||
|
||||
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
|
||||
def refdata(self, **kw):
|
||||
env = request.env
|
||||
techs = env['res.users'].search([('x_fc_is_field_staff', '=', True)]) \
|
||||
if 'x_fc_is_field_staff' in env['res.users']._fields else env['res.users'].search([])
|
||||
rates = env['fusion.service.rate'].search([('rate_kind', '=', 'callout'), ('active', '=', True)])
|
||||
per_km = env['fusion.service.rate'].get_rate('per_km')
|
||||
def labour(code):
|
||||
r = env['fusion.service.rate'].get_rate(code)
|
||||
return r.price if r else 0.0
|
||||
return {
|
||||
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
|
||||
'callout_rates': [{
|
||||
'code': r.code, 'category': r.category, 'timing': r.timing,
|
||||
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
|
||||
} for r in rates],
|
||||
'per_km': per_km.price if per_km else 0.70,
|
||||
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
|
||||
'lift': labour('labour_lift')},
|
||||
}
|
||||
|
||||
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
|
||||
def submit(self, payload=None, **kw):
|
||||
try:
|
||||
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
```
|
||||
|
||||
Modify `fusion_claims/__init__.py` (append):
|
||||
|
||||
```python
|
||||
from . import controllers
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run — verify it passes** (`--test-tags /fusion_claims.TestServiceBooking`). Also `pyflakes` the controller: `docker exec odoo-dev-app python3 -m pyflakes /mnt/extra-addons/fusion_claims/controllers/service_booking.py`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/models/technician_task.py fusion_claims/controllers/ fusion_claims/__init__.py fusion_claims/tests/test_service_booking.py
|
||||
git commit -m "feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: OWL booking wizard + SCSS (`fusion_claims`)
|
||||
|
||||
**Files:** create `static/src/js/service_booking/service_booking.js`, `static/src/xml/service_booking.xml`, `static/src/scss/_service_booking_tokens.scss`, `static/src/scss/service_booking.scss`; modify `__manifest__.py` (assets). **No unit test — manual smoke.**
|
||||
|
||||
- [ ] **Step 1: Write the OWL component**
|
||||
|
||||
Create `fusion_claims/static/src/js/service_booking/service_booking.js`:
|
||||
|
||||
```javascript
|
||||
/** @odoo-module **/
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ServiceBookingWizard extends Component {
|
||||
static template = "fusion_claims.ServiceBookingWizard";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
custMode: "existing", customer: {name:"",phone:"",email:"",street:"",unit:"",buzz:"",city:""},
|
||||
partnerId: false, soSearch: "",
|
||||
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
|
||||
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
|
||||
warranty: false, pod: false, emailConfirm: true, googleReview: true,
|
||||
description: "", materials: "",
|
||||
technicians: [], calloutRates: [], perKm: 0.70,
|
||||
labour: {onsite:85, inshop:75, lift:110}, distanceKm: 13, saving: false,
|
||||
});
|
||||
onWillStart(async () => {
|
||||
const r = await rpc("/fusion_claims/service_booking/refdata", {});
|
||||
Object.assign(this.state, {
|
||||
technicians: r.technicians, calloutRates: r.callout_rates,
|
||||
perKm: r.per_km, labour: r.labour,
|
||||
});
|
||||
});
|
||||
}
|
||||
get callout() {
|
||||
if (this.state.inShop) return null;
|
||||
return this.state.calloutRates.find(
|
||||
r => r.category === this.state.category && r.timing === this.state.timing) || null;
|
||||
}
|
||||
get labourRate() {
|
||||
if (this.state.inShop) return this.state.labour.inshop;
|
||||
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
|
||||
}
|
||||
get estimate() {
|
||||
const c = this.callout;
|
||||
const callout = c ? c.price : 0;
|
||||
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
|
||||
const billHr = Math.max(0, this.state.durationHr - incl);
|
||||
const labour = billHr * this.labourRate;
|
||||
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
|
||||
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
|
||||
}
|
||||
get endLabel() {
|
||||
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
|
||||
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
|
||||
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
|
||||
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
|
||||
}
|
||||
onDevice(ev) { this.state.device = ev.target.value; this.state.category = ev.target.value === "lift" ? "lift" : "standard"; }
|
||||
setCust(m) { this.state.custMode = m; }
|
||||
setTiming(t) { this.state.timing = t; }
|
||||
setAmpm(v) { this.state.ampm = v; }
|
||||
toggleInShop() { this.state.inShop = !this.state.inShop; }
|
||||
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
|
||||
|
||||
async submit() {
|
||||
if (this.state.saving) return;
|
||||
const s = this.state;
|
||||
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
|
||||
this.notification.add("Client name and phone are required.", { type: "danger" }); return;
|
||||
}
|
||||
s.saving = true;
|
||||
const payload = {
|
||||
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
|
||||
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
|
||||
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
|
||||
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
|
||||
email_confirm: s.emailConfirm, google_review: s.googleReview,
|
||||
description: s.description, materials: s.materials,
|
||||
};
|
||||
try {
|
||||
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
|
||||
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
|
||||
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window", res_model: "fusion.technician.task",
|
||||
res_id: res.task_id, views: [[false, "form"]], target: "current",
|
||||
});
|
||||
} catch (e) {
|
||||
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
|
||||
s.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the OWL template** — port the mockup
|
||||
|
||||
Create `fusion_claims/static/src/xml/service_booking.xml` with `<t t-name="fusion_claims.ServiceBookingWizard">`. **Port each section from the mockup** (`docs/superpowers/mockups/technician-booking-wizard.html`) converting static HTML → OWL bindings, per this exact mapping:
|
||||
|
||||
| Mockup element | OWL binding |
|
||||
|---|---|
|
||||
| `class="theme-btn"` | *remove* — Odoo handles dark/light via the bundle (Step 4) |
|
||||
| Customer `Existing/New` seg buttons | `t-att-class="{on: state.custMode==='existing'}"` + `t-on-click="() => setCust('existing')"` |
|
||||
| New-client inputs | `t-model="state.customer.name"` etc. (name, phone, email, street, unit, buzz, city) |
|
||||
| `<select id="device">` | `t-on-change="onDevice"` (options: scooter/powerchair/wheelchair→standard, stairlift/lift→lift, …) |
|
||||
| `<select id="callType">` | render from `state.calloutRates` with `t-foreach`; bind selection to category+timing |
|
||||
| timing seg | `t-on-click` → `setTiming('normal'|'rush'|'afterhours')` |
|
||||
| `feeAmt` / `eCall`/`eLab`/`eKm`/`eTotal` | `t-esc="estimate.callout"` etc. (format with a `fmt(n)` helper or `t-out`) |
|
||||
| in-shop switch | `t-att-class="{on: state.inShop}"` + `t-on-click="toggleInShop"` |
|
||||
| AM/PM buttons | `t-on-click` → `setAmpm('AM'|'PM')`; hour/minute `t-model.number` |
|
||||
| `endlbl` | `t-esc="endLabel"` |
|
||||
| technician `<select>` | `t-foreach="state.technicians"` + `t-model.number="state.technicianId"` |
|
||||
| switches (warranty/pod/email/review) | `t-att-class="{on: state.warranty}"` + `t-on-click="() => state.warranty = !state.warranty"` |
|
||||
| footer `Book & Create SO` | `t-on-click="submit"` `t-att-disabled="state.saving"` |
|
||||
|
||||
Keep the mockup's class names so the SCSS (Step 3) applies unchanged. Wrap the root in `<div class="o_service_booking">…</div>`.
|
||||
|
||||
- [ ] **Step 3: Port the SCSS (dark/light)**
|
||||
|
||||
Create `fusion_claims/static/src/scss/_service_booking_tokens.scss` — the mockup's `:root`/`[data-theme]` token values, converted to the repo's compile-time branch (per `CLAUDE.md` dark-mode rule):
|
||||
|
||||
```scss
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_page:#eef0f3; $_panel:#e6e9ed; $_card:#ffffff; $_border:#d8dadd; $_text:#1f2430;
|
||||
$_muted:#6b7280; $_field:#ffffff; $_money:#0f7d4e; $_money-soft:#e7f6ee; // …copy the rest from the mockup :root
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_page:#14161b !global; $_panel:#1b1e24 !global; $_card:#22262d !global; $_border:#343a42 !global;
|
||||
$_text:#e7eaef !global; $_muted:#9aa3af !global; $_field:#1a1d23 !global;
|
||||
$_money:#34d27f !global; $_money-soft:#15281f !global; // …copy the dark values from the mockup [data-theme="dark"]
|
||||
}
|
||||
|
||||
.o_service_booking {
|
||||
--sb-page:#{$_page}; --sb-panel:#{$_panel}; --sb-card:#{$_card}; --sb-border:#{$_border};
|
||||
--sb-text:#{$_text}; --sb-muted:#{$_muted}; --sb-field:#{$_field};
|
||||
--sb-money:#{$_money}; --sb-money-soft:#{$_money-soft}; /* …rest */
|
||||
}
|
||||
```
|
||||
|
||||
Create `fusion_claims/static/src/scss/service_booking.scss` — the mockup's component CSS, scoped under `.o_service_booking` and using the `--sb-*` vars instead of the mockup's `--page` etc. (mechanical rename). Drop the `.theme-btn` rule.
|
||||
|
||||
- [ ] **Step 4: Register assets** in `__manifest__.py`:
|
||||
|
||||
```python
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
# … existing entries …
|
||||
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
|
||||
'fusion_claims/static/src/scss/service_booking.scss',
|
||||
'fusion_claims/static/src/js/service_booking/service_booking.js',
|
||||
'fusion_claims/static/src/xml/service_booking.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
# dark bundle recompiles the same tokens with the dark scheme
|
||||
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
|
||||
'fusion_claims/static/src/scss/service_booking.scss',
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Smoke (manual, on the clone)**
|
||||
|
||||
`-u fusion_claims`, hard-refresh. Trigger the action (Task 6) → the wizard renders; toggle a user dark-mode profile to confirm the dark bundle; book a new client → task form opens, draft SO exists with the right call-out line.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/static/ fusion_claims/__manifest__.py
|
||||
git commit -m "feat(fusion_claims): OWL service-booking wizard + dark/light SCSS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Entry point + version bump
|
||||
|
||||
**Files:** create `fusion_claims/views/service_booking_action.xml`; modify `__manifest__.py`.
|
||||
|
||||
- [ ] **Step 1: Create the client action + menu**
|
||||
|
||||
Create `fusion_claims/views/service_booking_action.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="action_service_booking_wizard" model="ir.actions.client">
|
||||
<field name="name">Book a Service</field>
|
||||
<field name="tag">fusion_claims.service_booking</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_service_booking"
|
||||
name="Book a Service"
|
||||
parent="PARENT_MENU_XMLID"
|
||||
action="action_service_booking_wizard"
|
||||
sequence="1"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
Use the same Field-Service menu parent identified in Plan 1 Task 4 Step 2 (e.g. the technician-task app menu). Register in `__manifest__.py` `data` after the views.
|
||||
|
||||
- [ ] **Step 2: Bump version** in `__manifest__.py` (e.g. `19.0.9.3.0` → `19.0.9.4.0`).
|
||||
|
||||
- [ ] **Step 3: Full upgrade + all tests** (clone): `--test-tags /fusion_claims,/fusion_tasks`. Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: End-to-end smoke (clone browser)** — *Book a Service* menu → existing customer path (SO search prefill) and new-client path; confirm task + draft repair SO + correct call-out; rush/after-hours adds the per-km line; reschedule lands at the right local time (Task 1).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/views/service_booking_action.xml fusion_claims/__manifest__.py
|
||||
git commit -m "feat(fusion_claims): Book a Service entry point + version bump"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (done while writing)
|
||||
|
||||
- **Spec coverage:** tz fix §8 ✓ (T1); constraint relax §6.3 ✓ (T2); repair-SO flag/tag §6.3 ✓ (T2); resolver reads rate table §7 ✓ (T3); SO builder + per-km §7 ✓ (T3); `action_book_from_wizard` (contact→task→distance→SO) §5 ✓ (T4); OWL wizard + dark/light SCSS §5 ✓ (T5); entry point §11 ✓ (T6). Estimate-as-UI-only §9 ✓ (component `estimate` getter, not written to SO).
|
||||
- **Placeholders:** none for logic. Two deliberate lookups — the menu parent xmlid (T6/Plan-1) and the field-name verification in T4 (real "read the model first" per rule #1), both concrete actions, not vague TODOs. The template/SCSS port references the **mockup** (a complete existing artifact) with an explicit element→binding mapping — concrete, not "similar to".
|
||||
- **Type/name consistency:** `_resolve_service_lines(category, timing, in_shop, distance_km)` and `_build_service_so(partner, category, timing, in_shop, distance_km)` match across T3 tests, T4 caller, and the controller. Rate codes (`callout_standard_rush`, `per_km`, `labour_onsite/inshop/lift`) match Plan 1's seed. Controller routes `/fusion_claims/service_booking/{refdata,submit}` match the OWL `rpc()` calls. `action_book_from_wizard` return shape `{task_id, order_id}` matches the component's `res.task_id`.
|
||||
- **Flagged for execution:** verify real task field names in T4 (`in_store`/`client_name`/`address_lat`…) and the `crm.tag` vs `sale.order` tag model in T2 — both have explicit verify steps.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Both plans are written:
|
||||
- **Plan 1** — `…/plans/2026-06-03-service-rates-foundation-plan.md`
|
||||
- **Plan 2** — this file.
|
||||
|
||||
**Order:** Plan 1 → Plan 2 (Plan 2 consumes Plan 1's rate table). First move the work to a dedicated branch: `git checkout -b claude/technician-service-booking` (off `main`, *not* the fusion_schedule-fix branch).
|
||||
|
||||
Two execution options (per the writing-plans skill):
|
||||
1. **Subagent-Driven (recommended)** — a fresh subagent per task, reviewed between tasks. Best given the Enterprise-clone test loop.
|
||||
2. **Inline Execution** — execute tasks in this session with checkpoints.
|
||||
|
||||
**Caveat:** verification requires the Westin Enterprise clone (no local Community install). Plan to run the test/smoke steps there.
|
||||
@@ -0,0 +1,718 @@
|
||||
# Service Rates Foundation — Implementation Plan (Plan 1 of 2)
|
||||
|
||||
> **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:** Add an editable `fusion.service.rate` table (the Westin rate card, admin-managed from a **Service Rates** menu) that the booking wizard (Plan 2) will price from.
|
||||
|
||||
**Architecture:** A new `fusion.service.rate` model in `fusion_claims` (owns SO + products). Each row holds an editable `price` and links to a `product.product` (for SO-line description/tax/account). Seeded once (`noupdate=1`) from the rate card; admins own it thereafter. Two resolver methods (`get_callout`, `get_rate`) are the read API for Plan 2.
|
||||
|
||||
**Tech Stack:** Odoo 19 (Python ORM, declarative `models.Constraint`, XML data/views, `TransactionCase`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-03-technician-service-booking-design.md` (§3, §6.1).
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Testing reality (read before executing)
|
||||
|
||||
`fusion_claims` is **Enterprise-only** (depends `ai`) → it **cannot install on local `odoo-modsdev` (Community)**. Tests here are real `TransactionCase` tests but they run on a **Westin Enterprise clone** (see the repo `CLAUDE.md` *Westin Prod — Clone-Verify* section). Canonical run on the clone host:
|
||||
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d westin-v19-ratetest --test-enable --test-tags /fusion_claims \
|
||||
-u fusion_claims --stop-after-init --no-http --workers 0 --log-level=test \
|
||||
--db_host db --db_user odoo --db_password 'DevSecure2025!' 2>&1 | tail -60
|
||||
```
|
||||
|
||||
Where a step says "Run the test", it means *on the clone*. If the clone isn't available during a step, verify the logic via `odoo shell -d <clone>` instead and check the box once confirmed. **Do not** attempt `-d modsdev` (it can't install the module).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `fusion_claims/models/service_rate.py` *(create)* | `fusion.service.rate` model: fields, unique-code constraint, `get_callout` / `get_rate` resolvers |
|
||||
| `fusion_claims/models/__init__.py` *(modify)* | import `service_rate` |
|
||||
| `fusion_claims/data/service_rate_products.xml` *(create)* | seed `product.product` service products (one per rate) — `noupdate=1` |
|
||||
| `fusion_claims/data/service_rate_data.xml` *(create)* | seed `fusion.service.rate` rows linking the products — `noupdate=1` |
|
||||
| `fusion_claims/views/service_rate_views.xml` *(create)* | list + form + action + **Service Rates** menu |
|
||||
| `fusion_claims/security/ir.model.access.csv` *(modify)* | ACL: read for users, full for system/managers |
|
||||
| `fusion_claims/__manifest__.py` *(modify)* | register the 3 new data/view files; bump version |
|
||||
| `fusion_claims/tests/test_service_rate.py` *(create)* | model + resolver + seed tests |
|
||||
| `fusion_claims/tests/__init__.py` *(modify)* | import the test |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `fusion.service.rate` model + resolvers
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_claims/models/service_rate.py`
|
||||
- Modify: `fusion_claims/models/__init__.py`
|
||||
- Test: `fusion_claims/tests/test_service_rate.py`, `fusion_claims/tests/__init__.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `fusion_claims/tests/test_service_rate.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestServiceRate(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Rate = cls.env['fusion.service.rate']
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'Test Service Product', 'type': 'service',
|
||||
})
|
||||
|
||||
def _make(self, **kw):
|
||||
vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard',
|
||||
timing='normal', product_id=self.product.id, price=95.0, unit='fixed')
|
||||
vals.update(kw)
|
||||
return self.Rate.create(vals)
|
||||
|
||||
def test_get_callout_matches_category_and_timing(self):
|
||||
r = self._make(code='callout_standard_normal', category='standard', timing='normal', price=95.0)
|
||||
self._make(code='callout_lift_normal', category='lift', timing='normal', price=160.0)
|
||||
self.assertEqual(self.Rate.get_callout('standard', 'normal'), r)
|
||||
|
||||
def test_get_callout_in_shop_returns_empty(self):
|
||||
self._make(code='callout_standard_normal_b')
|
||||
self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True))
|
||||
|
||||
def test_get_rate_by_code(self):
|
||||
r = self._make(code='per_km', rate_kind='travel', category='na', timing='na', unit='per_km', price=0.70)
|
||||
self.assertEqual(self.Rate.get_rate('per_km'), r)
|
||||
|
||||
def test_code_must_be_unique(self):
|
||||
self._make(code='dup')
|
||||
with self.assertRaises(Exception):
|
||||
self._make(code='dup')
|
||||
self.env.flush_all()
|
||||
```
|
||||
|
||||
Register it in `fusion_claims/tests/__init__.py` (append):
|
||||
|
||||
```python
|
||||
from . import test_service_rate
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test — verify it fails**
|
||||
|
||||
Run (on the clone): the canonical command above with `--test-tags /fusion_claims.TestServiceRate`.
|
||||
Expected: FAIL — `KeyError: 'fusion.service.rate'` (model does not exist yet).
|
||||
|
||||
- [ ] **Step 3: Create the model**
|
||||
|
||||
Create `fusion_claims/models/service_rate.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionServiceRate(models.Model):
|
||||
_name = 'fusion.service.rate'
|
||||
_description = 'Field Service Rate'
|
||||
_order = 'sequence, rate_kind, category, timing'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(
|
||||
string='Code', required=True, index=True,
|
||||
help='Stable code used by the booking engine, e.g. callout_standard_normal, per_km.',
|
||||
)
|
||||
rate_kind = fields.Selection([
|
||||
('callout', 'Service Call-out'),
|
||||
('labour', 'Labour'),
|
||||
('travel', 'Travel / per-km'),
|
||||
('delivery', 'Delivery / Pickup'),
|
||||
('other', 'Other'),
|
||||
], string='Kind', required=True, default='callout')
|
||||
category = fields.Selection([
|
||||
('standard', 'Standard'),
|
||||
('lift', 'Lift & Elevating'),
|
||||
('na', 'N/A'),
|
||||
], string='Category', default='na')
|
||||
timing = fields.Selection([
|
||||
('normal', 'Normal'),
|
||||
('rush', 'Rush'),
|
||||
('afterhours', 'After-Hours'),
|
||||
('na', 'N/A'),
|
||||
], string='Timing', default='na')
|
||||
in_shop = fields.Boolean(string='In-Shop')
|
||||
product_id = fields.Many2one(
|
||||
'product.product', string='Invoice Product', required=True, ondelete='restrict',
|
||||
help='Product used on the sale-order line (description, tax, income account).',
|
||||
)
|
||||
price = fields.Monetary(
|
||||
string='Rate', required=True, currency_field='currency_id',
|
||||
help='Editable price used on the SO line and the on-screen estimate.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
unit = fields.Selection([
|
||||
('fixed', 'Flat'),
|
||||
('per_hour', 'Per hour'),
|
||||
('per_km', 'Per km'),
|
||||
], string='Unit', required=True, default='fixed')
|
||||
adds_per_km = fields.Boolean(
|
||||
string='Adds per-km travel',
|
||||
help='Call-outs billed as $X + per-km × 2-way (rush / after-hours).',
|
||||
)
|
||||
included_labour_min = fields.Integer(
|
||||
string='Included labour (min)', default=0,
|
||||
help='Free labour minutes bundled into a service call (e.g. 30).',
|
||||
)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
_unique_code = models.Constraint(
|
||||
'UNIQUE(code)',
|
||||
'A service-rate code must be unique.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_callout(self, category, timing, in_shop=False):
|
||||
"""Active call-out rate for category+timing. Empty recordset when in-shop."""
|
||||
if in_shop:
|
||||
return self.browse()
|
||||
return self.search([
|
||||
('rate_kind', '=', 'callout'),
|
||||
('category', '=', category),
|
||||
('timing', '=', timing),
|
||||
], limit=1)
|
||||
|
||||
@api.model
|
||||
def get_rate(self, code):
|
||||
"""Active rate row by code (e.g. 'per_km', 'labour_onsite')."""
|
||||
return self.search([('code', '=', code)], limit=1)
|
||||
```
|
||||
|
||||
Add to `fusion_claims/models/__init__.py` (append a line near the other imports):
|
||||
|
||||
```python
|
||||
from . import service_rate
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test — verify it passes**
|
||||
|
||||
Run (on the clone) with `--test-tags /fusion_claims.TestServiceRate`.
|
||||
Expected: PASS (4 tests). If `test_code_must_be_unique` errors instead of failing cleanly, the unique constraint is firing — that is the pass condition (it raises).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/models/service_rate.py fusion_claims/models/__init__.py \
|
||||
fusion_claims/tests/test_service_rate.py fusion_claims/tests/__init__.py
|
||||
git commit -m "feat(fusion_claims): add fusion.service.rate model + resolvers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Seed the service-rate products
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_claims/data/service_rate_products.xml`
|
||||
- Modify: `fusion_claims/__manifest__.py`
|
||||
|
||||
Products back each rate row (SO line description/tax/account). UoM: hour for labour, unit for everything else (per-km uses `unit` with qty = km×2 — avoids a custom km UoM). Taxes are **not** set here (matches the existing `LABOR` product convention — taxes applied per-DB by an admin).
|
||||
|
||||
- [ ] **Step 1: Create the product seed data**
|
||||
|
||||
Create `fusion_claims/data/service_rate_products.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Call-outs (unit) -->
|
||||
<record id="product_callout_standard_normal" model="product.template">
|
||||
<field name="name">Service Call — Standard</field>
|
||||
<field name="default_code">SVC-STD</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">95.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_standard_rush" model="product.template">
|
||||
<field name="name">Service Call — Standard Rush</field>
|
||||
<field name="default_code">SVC-STD-RUSH</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">120.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_standard_afterhours" model="product.template">
|
||||
<field name="name">Service Call — Standard After-Hours</field>
|
||||
<field name="default_code">SVC-STD-AH</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">140.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_lift_normal" model="product.template">
|
||||
<field name="name">Service Call — Lift & Elevating</field>
|
||||
<field name="default_code">SVC-LIFT</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">160.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_lift_rush" model="product.template">
|
||||
<field name="name">Service Call — Lift & Elevating Rush</field>
|
||||
<field name="default_code">SVC-LIFT-RUSH</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">185.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_lift_afterhours" model="product.template">
|
||||
<field name="name">Service Call — Lift & Elevating After-Hours</field>
|
||||
<field name="default_code">SVC-LIFT-AH</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">205.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Labour (hour) -->
|
||||
<record id="product_labour_onsite" model="product.template">
|
||||
<field name="name">Labour — On-Site</field>
|
||||
<field name="default_code">LAB-ONSITE</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">85.00</field>
|
||||
<field name="uom_id" ref="uom.product_uom_hour"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_hour"/>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_labour_lift" model="product.template">
|
||||
<field name="name">Labour — Lift & Elevating</field>
|
||||
<field name="default_code">LAB-LIFT</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">110.00</field>
|
||||
<field name="uom_id" ref="uom.product_uom_hour"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_hour"/>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Travel (unit; qty = km × 2) -->
|
||||
<record id="product_per_km" model="product.template">
|
||||
<field name="name">Travel — per km (2-way)</field>
|
||||
<field name="default_code">SVC-KM</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">0.70</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Delivery / pickup (unit) -->
|
||||
<record id="product_delivery_local" model="product.template">
|
||||
<field name="name">Delivery / Pickup — Local</field>
|
||||
<field name="default_code">DEL-LOCAL</field>
|
||||
<field name="type">service</field><field name="list_price">35.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_delivery_outside" model="product.template">
|
||||
<field name="name">Delivery / Pickup — Outside Local Area</field>
|
||||
<field name="default_code">DEL-OUT</field>
|
||||
<field name="type">service</field><field name="list_price">60.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_delivery_rush" model="product.template">
|
||||
<field name="name">Rush Pickup / Delivery</field>
|
||||
<field name="default_code">DEL-RUSH</field>
|
||||
<field name="type">service</field><field name="list_price">60.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_setup_liftchair" model="product.template">
|
||||
<field name="name">Lift Chair — Delivery & Set-up</field>
|
||||
<field name="default_code">SETUP-LIFTCHAIR</field>
|
||||
<field name="type">service</field><field name="list_price">120.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_setup_hospitalbed" model="product.template">
|
||||
<field name="name">Hospital Bed — Delivery & Set-up</field>
|
||||
<field name="default_code">SETUP-BED</field>
|
||||
<field name="type">service</field><field name="list_price">120.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_setup_stairlift" model="product.template">
|
||||
<field name="name">Stairlift — Delivery & Set-up</field>
|
||||
<field name="default_code">SETUP-STAIRLIFT</field>
|
||||
<field name="type">service</field><field name="list_price">300.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_removal_stairlift" model="product.template">
|
||||
<field name="name">Stairlift — Removal</field>
|
||||
<field name="default_code">RMV-STAIRLIFT</field>
|
||||
<field name="type">service</field><field name="list_price">300.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register in the manifest**
|
||||
|
||||
In `fusion_claims/__manifest__.py`, add to the `data` list **immediately after** `'data/product_labor_data.xml'`:
|
||||
|
||||
```python
|
||||
'data/service_rate_products.xml',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify load (on the clone)**
|
||||
|
||||
Run: `docker exec odoo-dev-app odoo -d westin-v19-ratetest -u fusion_claims --stop-after-init --no-http --workers 0 --db_host db --db_user odoo --db_password 'DevSecure2025!' 2>&1 | tail -20`
|
||||
Expected: no error; module upgraded. (No test yet — products are referenced by Task 3's data.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/data/service_rate_products.xml fusion_claims/__manifest__.py
|
||||
git commit -m "feat(fusion_claims): seed service-rate products"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Seed the rate rows
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_claims/data/service_rate_data.xml`
|
||||
- Modify: `fusion_claims/__manifest__.py`
|
||||
- Test: `fusion_claims/tests/test_service_rate.py`
|
||||
|
||||
`product.template` external IDs from Task 2 resolve to a `product.product` via `.product_variant_id`. In data XML, reference the variant with `ref="product_callout_standard_normal_product_template"`? No — simplest is to point `product_id` at the template's variant using the template's xmlid is not valid for a `product.product` m2o. Use a tiny Python step instead: a `post_init`-style noupdate is awkward for m2o-to-variant. **Approach:** reference the product *variant* created automatically. Odoo creates `product.product` for each template; its xmlid is `<template_xmlid>_product_variant`? It is **not** auto-created. So we set `product_id` by searching on `default_code` in a noupdate `function`. Keep it simple and deterministic:
|
||||
|
||||
- [ ] **Step 1: Write the failing test (seed assertions)**
|
||||
|
||||
Append to `fusion_claims/tests/test_service_rate.py`:
|
||||
|
||||
```python
|
||||
def test_seeded_callouts_exist(self):
|
||||
# standard normal $95, lift after-hours $205 are the canonical seeds
|
||||
std = self.env.ref('fusion_claims.rate_callout_standard_normal')
|
||||
self.assertEqual(std.price, 95.0)
|
||||
self.assertEqual(std.rate_kind, 'callout')
|
||||
self.assertTrue(std.product_id)
|
||||
lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours')
|
||||
self.assertEqual(lift_ah.price, 205.0)
|
||||
self.assertTrue(lift_ah.adds_per_km)
|
||||
|
||||
def test_seeded_per_km(self):
|
||||
km = self.env['fusion.service.rate'].get_rate('per_km')
|
||||
self.assertTrue(km)
|
||||
self.assertEqual(km.unit, 'per_km')
|
||||
self.assertEqual(km.price, 0.70)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — verify it fails**
|
||||
|
||||
Run with `--test-tags /fusion_claims.TestServiceRate`.
|
||||
Expected: FAIL — `ValueError: External ID not found: fusion_claims.rate_callout_standard_normal`.
|
||||
|
||||
- [ ] **Step 3: Create the rate seed data**
|
||||
|
||||
Create `fusion_claims/data/service_rate_data.xml`. Each rate's `product_id` is set with `eval` that resolves the template's variant at load time:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- CALL-OUTS -->
|
||||
<record id="rate_callout_standard_normal" model="fusion.service.rate">
|
||||
<field name="name">Standard Service Call</field>
|
||||
<field name="code">callout_standard_normal</field>
|
||||
<field name="rate_kind">callout</field><field name="category">standard</field>
|
||||
<field name="timing">normal</field><field name="unit">fixed</field>
|
||||
<field name="included_labour_min">30</field><field name="price">95.0</field>
|
||||
<field name="product_id" ref="product_callout_standard_normal_product_variant"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="rate_callout_standard_rush" model="fusion.service.rate">
|
||||
<field name="name">Rush Service Call (Standard)</field>
|
||||
<field name="code">callout_standard_rush</field>
|
||||
<field name="rate_kind">callout</field><field name="category">standard</field>
|
||||
<field name="timing">rush</field><field name="unit">fixed</field>
|
||||
<field name="adds_per_km" eval="True"/><field name="price">120.0</field>
|
||||
<field name="product_id" ref="product_callout_standard_rush_product_variant"/>
|
||||
<field name="sequence">11</field>
|
||||
</record>
|
||||
<record id="rate_callout_standard_afterhours" model="fusion.service.rate">
|
||||
<field name="name">After-Hours Service Call (Standard)</field>
|
||||
<field name="code">callout_standard_afterhours</field>
|
||||
<field name="rate_kind">callout</field><field name="category">standard</field>
|
||||
<field name="timing">afterhours</field><field name="unit">fixed</field>
|
||||
<field name="adds_per_km" eval="True"/><field name="price">140.0</field>
|
||||
<field name="product_id" ref="product_callout_standard_afterhours_product_variant"/>
|
||||
<field name="sequence">12</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_normal" model="fusion.service.rate">
|
||||
<field name="name">Lift & Elevating Service Call</field>
|
||||
<field name="code">callout_lift_normal</field>
|
||||
<field name="rate_kind">callout</field><field name="category">lift</field>
|
||||
<field name="timing">normal</field><field name="unit">fixed</field>
|
||||
<field name="included_labour_min">30</field><field name="price">160.0</field>
|
||||
<field name="product_id" ref="product_callout_lift_normal_product_variant"/>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_rush" model="fusion.service.rate">
|
||||
<field name="name">Lift & Elevating Rush Call</field>
|
||||
<field name="code">callout_lift_rush</field>
|
||||
<field name="rate_kind">callout</field><field name="category">lift</field>
|
||||
<field name="timing">rush</field><field name="unit">fixed</field>
|
||||
<field name="adds_per_km" eval="True"/><field name="price">185.0</field>
|
||||
<field name="product_id" ref="product_callout_lift_rush_product_variant"/>
|
||||
<field name="sequence">21</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_afterhours" model="fusion.service.rate">
|
||||
<field name="name">Lift & Elevating After-Hours Call</field>
|
||||
<field name="code">callout_lift_afterhours</field>
|
||||
<field name="rate_kind">callout</field><field name="category">lift</field>
|
||||
<field name="timing">afterhours</field><field name="unit">fixed</field>
|
||||
<field name="adds_per_km" eval="True"/><field name="price">205.0</field>
|
||||
<field name="product_id" ref="product_callout_lift_afterhours_product_variant"/>
|
||||
<field name="sequence">22</field>
|
||||
</record>
|
||||
|
||||
<!-- LABOUR -->
|
||||
<record id="rate_labour_onsite" model="fusion.service.rate">
|
||||
<field name="name">Labour — On-Site</field><field name="code">labour_onsite</field>
|
||||
<field name="rate_kind">labour</field><field name="category">standard</field>
|
||||
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">85.0</field>
|
||||
<field name="product_id" ref="product_labour_onsite_product_variant"/><field name="sequence">30</field>
|
||||
</record>
|
||||
<record id="rate_labour_lift" model="fusion.service.rate">
|
||||
<field name="name">Labour — Lift & Elevating</field><field name="code">labour_lift</field>
|
||||
<field name="rate_kind">labour</field><field name="category">lift</field>
|
||||
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">110.0</field>
|
||||
<field name="product_id" ref="product_labour_lift_product_variant"/><field name="sequence">31</field>
|
||||
</record>
|
||||
<record id="rate_labour_inshop" model="fusion.service.rate">
|
||||
<field name="name">Labour — In-Shop</field><field name="code">labour_inshop</field>
|
||||
<field name="rate_kind">labour</field><field name="category">na</field><field name="in_shop" eval="True"/>
|
||||
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">75.0</field>
|
||||
<field name="product_id" ref="product_labor_hourly_product_variant"/><field name="sequence">32</field>
|
||||
</record>
|
||||
|
||||
<!-- TRAVEL -->
|
||||
<record id="rate_per_km" model="fusion.service.rate">
|
||||
<field name="name">Travel — per km (2-way)</field><field name="code">per_km</field>
|
||||
<field name="rate_kind">travel</field><field name="category">na</field>
|
||||
<field name="timing">na</field><field name="unit">per_km</field><field name="price">0.70</field>
|
||||
<field name="product_id" ref="product_per_km_product_variant"/><field name="sequence">40</field>
|
||||
</record>
|
||||
|
||||
<!-- DELIVERY / PICKUP -->
|
||||
<record id="rate_delivery_local" model="fusion.service.rate">
|
||||
<field name="name">Delivery / Pickup — Local</field><field name="code">delivery_local</field>
|
||||
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
|
||||
<field name="unit">fixed</field><field name="price">35.0</field>
|
||||
<field name="product_id" ref="product_delivery_local_product_variant"/><field name="sequence">50</field>
|
||||
</record>
|
||||
<record id="rate_delivery_outside" model="fusion.service.rate">
|
||||
<field name="name">Delivery / Pickup — Outside Local Area</field><field name="code">delivery_outside</field>
|
||||
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
|
||||
<field name="unit">fixed</field><field name="price">60.0</field>
|
||||
<field name="product_id" ref="product_delivery_outside_product_variant"/><field name="sequence">51</field>
|
||||
</record>
|
||||
<record id="rate_setup_stairlift" model="fusion.service.rate">
|
||||
<field name="name">Stairlift — Delivery & Set-up</field><field name="code">setup_stairlift</field>
|
||||
<field name="rate_kind">delivery</field><field name="category">lift</field><field name="timing">na</field>
|
||||
<field name="unit">fixed</field><field name="price">300.0</field>
|
||||
<field name="product_id" ref="product_setup_stairlift_product_variant"/><field name="sequence">52</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
> **Note on `_product_variant` refs:** Odoo auto-creates the `product.product` for a single-variant `product.template` and assigns it the external ID `<template_xmlid>_product_variant`. This is the supported way to reference the variant from data XML. (The existing in-shop labour reuses `product_labor_hourly` from `product_labor_data.xml`, hence `product_labor_hourly_product_variant`.) If a `_product_variant` ref ever fails to resolve on your DB, the fallback is to set `product_id` via `eval="obj().env.ref('fusion_claims.product_xxx').product_variant_id.id"` — but try the `_product_variant` ref first.
|
||||
|
||||
Register in `fusion_claims/__manifest__.py`, **immediately after** `'data/service_rate_products.xml'`:
|
||||
|
||||
```python
|
||||
'data/service_rate_data.xml',
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test — verify it passes**
|
||||
|
||||
Run with `--test-tags /fusion_claims.TestServiceRate` (the `-u fusion_claims` reload loads the seed first).
|
||||
Expected: PASS (all tests incl. `test_seeded_callouts_exist`, `test_seeded_per_km`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/data/service_rate_data.xml fusion_claims/__manifest__.py fusion_claims/tests/test_service_rate.py
|
||||
git commit -m "feat(fusion_claims): seed service-rate rows from the rate card"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Security ACL + Service Rates views & menu
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_claims/security/ir.model.access.csv`
|
||||
- Create: `fusion_claims/views/service_rate_views.xml`
|
||||
- Modify: `fusion_claims/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Add the ACL rows**
|
||||
|
||||
Append to `fusion_claims/security/ir.model.access.csv`:
|
||||
|
||||
```csv
|
||||
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
|
||||
access_fusion_service_rate_manager,fusion.service.rate.manager,model_fusion_service_rate,base.group_system,1,1,1,1
|
||||
```
|
||||
|
||||
(Users read rates — the wizard needs that; system/managers edit. If `fusion_claims` defines a sales-manager group, swap the second row's group for it during review.)
|
||||
|
||||
- [ ] **Step 2: Find the parent menu**
|
||||
|
||||
Run: `grep -n "menuitem" fusion_claims/views/*.xml fusion_tasks/views/*.xml | grep -i "id=" | head -40`
|
||||
Pick the appropriate Configuration/root menu for "Service Rates" (e.g. the fusion_claims app root or a Field-Service config menu). Record its full xmlid (e.g. `fusion_claims.menu_fusion_claims_config` or `sale.menu_sale_config`). Use it as `parent=` in Step 3.
|
||||
|
||||
- [ ] **Step 3: Create the views**
|
||||
|
||||
Create `fusion_claims/views/service_rate_views.xml` (replace `PARENT_MENU_XMLID` with the id found in Step 2):
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_service_rate_view_list" model="ir.ui.view">
|
||||
<field name="name">fusion.service.rate.list</field>
|
||||
<field name="model">fusion.service.rate</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Service Rates" editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="rate_kind"/>
|
||||
<field name="category"/>
|
||||
<field name="timing"/>
|
||||
<field name="in_shop"/>
|
||||
<field name="unit"/>
|
||||
<field name="price"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="adds_per_km"/>
|
||||
<field name="product_id"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fusion_service_rate_view_form" model="ir.ui.view">
|
||||
<field name="name">fusion.service.rate.form</field>
|
||||
<field name="model">fusion.service.rate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Service Rate">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. Standard Service Call"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="rate_kind"/>
|
||||
<field name="category"/>
|
||||
<field name="timing"/>
|
||||
<field name="in_shop"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="price"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="unit"/>
|
||||
<field name="adds_per_km"/>
|
||||
<field name="included_labour_min"/>
|
||||
<field name="product_id"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_service_rate" model="ir.actions.act_window">
|
||||
<field name="name">Service Rates</field>
|
||||
<field name="res_model">fusion.service.rate</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">Define your field-service rate card</p>
|
||||
<p>Call-out fees, labour, per-km and delivery charges used by the service booking wizard.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_service_rate"
|
||||
name="Service Rates"
|
||||
parent="PARENT_MENU_XMLID"
|
||||
action="action_fusion_service_rate"
|
||||
sequence="90"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
Register in `fusion_claims/__manifest__.py` `data` list, **after** `'views/res_config_settings_views.xml'` (or near the other views):
|
||||
|
||||
```python
|
||||
'views/service_rate_views.xml',
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify load + menu (on the clone)**
|
||||
|
||||
Run the `-u fusion_claims --stop-after-init` command; expected: no error.
|
||||
Then in `odoo shell -d westin-v19-ratetest`: `env.ref('fusion_claims.action_fusion_service_rate')` resolves; `env['fusion.service.rate'].search_count([])` ≥ 14. `env.cr.rollback()`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/security/ir.model.access.csv fusion_claims/views/service_rate_views.xml fusion_claims/__manifest__.py
|
||||
git commit -m "feat(fusion_claims): Service Rates menu, list (inline-edit) + form + ACL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Version bump + final verify
|
||||
|
||||
**Files:** Modify `fusion_claims/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Bump version**
|
||||
|
||||
In `fusion_claims/__manifest__.py`, bump `'version'` (e.g. `19.0.9.2.0` → `19.0.9.3.0`).
|
||||
|
||||
- [ ] **Step 2: Full upgrade + test run (on the clone)**
|
||||
|
||||
Run the canonical test command (`--test-tags /fusion_claims.TestServiceRate`). Expected: all PASS, module upgraded, no warnings about the new data files.
|
||||
|
||||
- [ ] **Step 3: Manual smoke (browser, on the clone)**
|
||||
|
||||
Open *Service Rates* menu → confirm 14+ rows, prices editable inline, a new row can be added and saved. Toggle one `active` off and back.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_claims/__manifest__.py
|
||||
git commit -m "chore(fusion_claims): bump version for service-rate foundation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (done while writing)
|
||||
|
||||
- **Spec coverage:** §6.1 model fields ✓ (Task 1), seed products ✓ (Task 2), seed rows incl. $185/$205 + per-km + labour + delivery ✓ (Task 3), Service Rates menu/views/ACL ✓ (Task 4), §3 values as seed ✓. Resolver API (`get_callout`/`get_rate`) ✓ (Task 1) — consumed by Plan 2.
|
||||
- **Placeholders:** none — every step has full code. The one deliberate lookup is the menu parent (Task 4 Step 2), which is a real "find the xmlid" action, not a vague TODO.
|
||||
- **Type/name consistency:** `get_callout(category, timing, in_shop)` and `get_rate(code)` signatures match the tests and the seed codes (`callout_standard_normal`, `per_km`, `labour_inshop` reusing `product_labor_hourly`). Rate `code`s match across data + tests.
|
||||
- **Gap noted for Plan 2:** the `_product_variant` external-ID convention (Task 3 note) — Plan 2's SO builder uses `rate.product_id` directly, so it's unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
This is **Plan 1 of 2**. **Plan 2** (booking wizard: tz fix, constraint relax, pricing resolver consuming `get_callout`/`get_rate`, SO builder, `action_book_from_wizard`, OWL wizard + SCSS, entry point) will be written next and depends on this.
|
||||
|
||||
Before executing: move this work to a dedicated branch (e.g. `claude/technician-service-booking`) — it's currently alongside the unrelated fusion_schedule fixes.
|
||||
@@ -0,0 +1,101 @@
|
||||
# NexaCloud → Odoo Centralized Billing — Cutover (build-out · dual-run · gated flip)
|
||||
|
||||
- **Date:** 2026-06-02
|
||||
- **Status:** Design approved — pending written-spec review
|
||||
- **Author:** Design session (Claude + Gurpreet)
|
||||
- **Parent spec:** [`2026-05-27-nexa-billing-centralized-design.md`](2026-05-27-nexa-billing-centralized-design.md) (architecture; this doc is its **phase #2** — the NexaCloud pilot)
|
||||
- **Repos:** `K:\Github\Odoo-Modules\fusion_centralize_billing` (engine) + `K:\Github\Nexa-Cloud` (the NexaCloud adapter)
|
||||
- **Hosts:** `odoo-nexa` (VM 315, Odoo 19 Enterprise, live DB `nexamain`); NexaCloud (LXC 102, app `192.168.1.250`, DB `192.168.1.50`)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Make Odoo (`fusion_centralize_billing` on `odoo-nexa`) the system of record for **NexaCloud** billing: build the engine pieces NexaCloud needs, import NexaCloud's active deployments as Odoo subscriptions, run Odoo in **shadow** alongside NexaCloud's existing Stripe billing for ≥1 cycle, reconcile to the cent, and then **flip** NexaCloud onto Odoo behind an explicit go/no-go gate. NexaCloud is the pilot; NexaDesk and NexaMaps follow in later increments. This does not touch Lago.
|
||||
|
||||
## 2. Decisions locked (this session, 2026-06-02)
|
||||
|
||||
1. **Sequence: NexaCloud first** (per parent spec), then NexaDesk, then NexaMaps.
|
||||
2. **Granularity: one Odoo subscription per NexaCloud deployment** (mirrors `nexacloud` `subscriptions.deployment_id`; the existing usage-push and `fusion.billing.reconciliation` code already key per deployment via `x_fc_nexacloud_subscription_id`).
|
||||
3. **Approach A: build → import → dual-run → gated flip**, all in this increment; the flip executes only after ≥1 green reconciliation cycle **and** explicit operator go-ahead.
|
||||
4. **Go-forward billing only.** The importer sets each subscription's `next_invoice_date` so Odoo bills only future periods. Past NexaCloud periods are **never re-issued** (this is the exact failure mode of the 2026-05-27 Lago incident — see `lago-doublecharge-incident-2026-06` memory).
|
||||
|
||||
## 3. Current state (recon, 2026-06-02)
|
||||
|
||||
Engine is **installed** on `nexamain` (`fusion_centralize_billing` v19.0.1.1.0; deps `sale_subscription`, `payment_stripe`, `account_accountant` installed). Runtime rows:
|
||||
|
||||
| Table | Rows | Read |
|
||||
|---|---|---|
|
||||
| `fusion_billing_service` | 1 | only `nexacloud`; **`webhook_url` empty** |
|
||||
| `fusion_billing_account_link` | 7 | identities imported |
|
||||
| `fusion_billing_metric` | 1 | (cpu_seconds) |
|
||||
| `fusion_billing_charge` | **0** | no quota/overage pricing yet |
|
||||
| `fusion_billing_usage` | **0** | nothing ingested |
|
||||
| `fusion_billing_reconciliation` | **0** | dual-run never run |
|
||||
| `fusion_billing_webhook` | **0** | control loop never fired |
|
||||
| `sale_order` (`is_subscription`) | **0** | no subscriptions exist |
|
||||
|
||||
Engine code status: `webhook.py` delivery engine (HMAC + backoff + dead-letter) is **complete** (its "TODO §8" header comment is stale); `usage.py` (idempotent upsert + pre-invoice rating cron + aggregation) and `reconciliation.py` (NexaCloud dual-run) are **complete**. `controllers/api.py` implements `/health`, `POST /customers`, `POST /usage`, `GET /plans`, `POST /subscriptions` only — the rest of parent-spec §7 is unimplemented (needed by NexaDesk, **not** NexaCloud).
|
||||
|
||||
NexaCloud adapter is present but **INERT**: `config.py` `odoo_billing_enabled=False`, `odoo_billing_base_url`/`odoo_billing_api_key` empty; `usage_metering.py` pushes `cpu_seconds` only when enabled; `routers/odoo_billing.py` `/billing/webhooks/central` returns 404 when disabled; `services/odoo_billing_integration.py` is the (inert) receiver. Lago is paused (worker+clock stopped) and out of scope here.
|
||||
|
||||
## 4. Scope
|
||||
|
||||
### 4.1 Odoo side (`fusion_centralize_billing` + catalog data on `nexamain`)
|
||||
|
||||
1. **Charge catalog (the main gap — currently 0).**
|
||||
- NexaCloud plans/products → `product.template` + `sale.subscription.plan` (monthly), each tagged `plan_code` and a `product.default_code` of `NC-PLAN-<slug>` (reconciliation already filters plan lines on `default_code LIKE 'NC-PLAN-%'`).
|
||||
- `cpu_seconds` metric (exists) → one `fusion.billing.charge` per plan: `included_quota` = the plan's bundled CPU-seconds, `price_per_unit`/`unit_batch` for overage derived from `usage_metering.HOURLY_RATES` (`cpu_per_core=$0.0075/core-hr` → per-cpu-second rate). Memory/disk are part of the flat plan today (not metered) — keep them flat unless a plan meters them.
|
||||
- Throttle-removal fee and the CPU/RAM/disk/daily-backup **add-ons** → one-off invoice products / optional recurring add-on products tagged `NC-ADDON-<slug>`.
|
||||
- HST: reuse native `account.tax` (13% ON); confirm the tax code matches what NexaCloud invoices apply today.
|
||||
2. **Run the importer** (`wizards/import_wizard.py`): read the `nexacloud` DB → ensure `res.partner` + `account.link` for each active customer (7 exist; backfill any missing), and create **one shadow `sale.order` (`is_subscription=True`) per active deployment**, setting `x_fc_nexacloud_subscription_id`, `x_fc_nexacloud_plan_id`, the `NC-PLAN-*` line, and **`next_invoice_date` = the deployment's next real billing date** (go-forward only). Subscriptions start in shadow (draft/not auto-charging).
|
||||
3. **Inbound API — add only what NexaCloud needs.** `POST /customers`, `POST /subscriptions`, `POST /usage`, `GET /plans` already exist. Add **subscription cancel** (`DELETE /subscriptions/:id` → terminate the `sale.order`) for NexaCloud's deprovision path. All other parent-spec §7 endpoints stay deferred to the NexaDesk increment.
|
||||
4. **Wire the control loop:** set the `nexacloud` `fusion.billing.service.webhook_url` → `https://api.vps.nexasystems.ca/api/v1/billing/webhooks/central`, and confirm `cron` schedules for `usage._cron_rate_open_periods` and `webhook._cron_dispatch` are enabled.
|
||||
|
||||
### 4.2 NexaCloud side (`Nexa-Cloud` repo)
|
||||
|
||||
4. **Configure + activate the adapter:** set `odoo_billing_base_url=https://erp.nexasystems.ca/api/billing/v1`, `odoo_billing_api_key=<nexacloud service key>`. Keep `odoo_billing_enabled` staged so usage push + the webhook receiver activate for shadow without yet disabling local Stripe.
|
||||
5. **Identity + subscription sync:** on deployment create / cancel, call Odoo `POST /customers` and `POST /subscriptions` / cancel (usage push already exists in `usage_metering.py`). Send a stable `external_id` (NexaCloud user id) and `subscription_external_id` (deployment/subscription id) — namespaced, to avoid the cross-app `external_id` collision noted in `nexa-billing-architecture`.
|
||||
6. **Reconciliation feed:** push NexaCloud's **actual** charged amount per (deployment, period) so `reconciliation._reconcile_rows` can diff Odoo-computed vs NexaCloud-actual. (Source: NexaCloud's own invoices/`usage_records`.)
|
||||
7. **Activate the control-loop receiver:** `routers/odoo_billing.py` `/billing/webhooks/central` → `services/odoo_billing_integration.py` maps `invoice.payment_failed`→suspend (existing `network_isolation`/`throttle_checker`/`resource_manager`), `invoice.payment_succeeded`/`subscription.reactivated`→restore, `subscription.terminated`→deprovision. Verify HMAC against the `nexacloud` service `webhook_secret`.
|
||||
|
||||
### 4.3 Dual-run (shadow, ≥1 billing cycle)
|
||||
|
||||
NexaCloud keeps charging via its own Stripe. Odoo computes **draft, uncharged** invoices from imported subscriptions + pushed `cpu_seconds`. `fusion.billing.reconciliation` upserts one row per `(service, deployment, period)` with `odoo_amount` vs `external_amount` and a cent-level `delta`. Operators investigate every `delta` row until a full cycle is `match` within tolerance (default $0.01).
|
||||
|
||||
### 4.4 Gated flip (after ≥1 green cycle + explicit go)
|
||||
|
||||
1. NexaCloud **stops its own Stripe charging** (disable the charge path in `billing_service.py` / scheduler `billing_payment` + invoice generation) and treats Odoo as SoR.
|
||||
2. Odoo subscriptions move from shadow → active; native subscription invoicing charges the **shared** Stripe account `acct_1ShlA9IkwUB1dVox` (saved cards carry over — no re-collection).
|
||||
3. Webhooks drive suspend/restore/deprovision. Past NexaCloud invoices remain archived (PDF/opening balance) — **not** re-issued.
|
||||
4. Rollback: re-enable NexaCloud local billing + set Odoo subs back to shadow (no data destroyed).
|
||||
|
||||
## 5. Out of scope (YAGNI for this increment)
|
||||
|
||||
- NexaDesk and NexaMaps adapters (later increments) and the inbound-API endpoints only they need (`/invoices` family, `/credit_notes`, `/catalog`, `/checkout_url`, `PUT /subscriptions` plan-change/upgrade).
|
||||
- Lago changes or decommission (Lago stays paused; its remediation is tracked separately).
|
||||
- Customer-portal redesign — use native Odoo portal as-is.
|
||||
- Metering memory/disk/bandwidth (stay flat unless a NexaCloud plan already meters them).
|
||||
|
||||
## 6. Success criteria
|
||||
|
||||
- A NexaCloud deployment is created as an Odoo subscription `sale.order` (`is_subscription=True`) via `POST /subscriptions`, resolving one `res.partner` through `account.link`.
|
||||
- `cpu_seconds` counters pushed to `/usage` aggregate (idempotent) into a **draft** invoice with quota → free, overage priced, HST applied — matching NexaCloud's own computed amount within $0.01.
|
||||
- A simulated `invoice.payment_failed` webhook reaches `/billing/webhooks/central` (valid HMAC) and triggers a NexaCloud suspend; `invoice.payment_succeeded` restores.
|
||||
- `fusion.billing.reconciliation` is `match` for **every** active deployment across ≥1 full cycle before any flip.
|
||||
- Re-sending the same usage counter (same `idempotency_key`) does **not** double-bill (constraint + upsert verified by test).
|
||||
- Post-flip: Odoo charges go-forward periods only; **zero** past-period re-issues.
|
||||
|
||||
## 7. Risks & open items
|
||||
|
||||
- **Re-billing regression (highest):** the importer MUST set `next_invoice_date` go-forward and must not finalize/charge historical periods. Add an explicit test asserting no invoice is generated for any period earlier than import time. (Direct mitigation of the 2026-05-27 Lago incident.)
|
||||
- **Odoo 19 correctness:** read live reference files from the container (`docker exec odoo-nexa-app cat …`) for `sale.order` subscription flow, `account.move`, `payment_stripe` before coding internals — never from memory (per `K:\Github\CLAUDE.md`).
|
||||
- **Idempotency:** `fusion.billing.usage` unique `(subscription, metric, idempotency_key)` already enforces it; the NexaCloud key is `nexacloud:cpu_seconds:<sub>:<period>` — keep it stable across retries.
|
||||
- **external_id namespacing:** NexaCloud must send namespaced ids so it can never collide with NexaDesk/NexaMaps in the shared Odoo identity space.
|
||||
- **Reconciliation source:** confirm where NexaCloud's "actual amount" comes from (its `invoices`/`usage_records`) and that it's net of the same HST basis Odoo uses.
|
||||
- **Flip switch safety:** disabling NexaCloud's local Stripe must be a single, reversible config flag, and the `billing_payment` scheduler job must be guarded so it can't charge once Odoo is SoR.
|
||||
- **Spec/branch target:** `Odoo-Modules` is on `feat/fusion-login-audit` with `-wt-portal`/`-wt-fm` worktrees; confirm the branch for engine changes; NexaCloud changes land on its own branch (note: pushing `Nexa-Cloud` `main` auto-deploys to prod).
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
- Odoo unit tests (extend `fusion_centralize_billing/tests/`): catalog→charge mapping; usage aggregation + quota/overage; idempotent re-push; reconciliation match/delta; webhook HMAC sign/verify + backoff; **importer go-forward `next_invoice_date` assertion**.
|
||||
- NexaCloud tests: adapter customer/subscription calls; `/billing/webhooks/central` HMAC verify + suspend/restore/deprovision dispatch; reconciliation-amount push.
|
||||
- Dual-run acceptance: a full cycle of `match` reconciliation on real (or staged) deployments before the flip gate.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Technician Service Booking & Auto-Quote — Design Spec
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Modules:** `fusion_tasks` (booking wizard, task, time/tz), `fusion_claims` (SO link, rate-card products, SO creation)
|
||||
**Status:** Draft for review
|
||||
**Mockup:** `docs/superpowers/mockups/technician-booking-wizard.html` (v2)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem & Goal
|
||||
|
||||
Operators booking a technician service today use the raw `fusion.technician.task` form in a modal. Three problems:
|
||||
|
||||
1. **Forced SO:** a hard constraint (`fusion_claims/models/technician_task.py:105 _check_order_link`) requires a Sale Order **or** Purchase Order for every task except `ltc_visit`. A repair for a brand-new client (no SO yet) is blocked.
|
||||
2. **Time fields:** Start/End use a 24-hour `float_time` widget while every other view shows 12-hour AM/PM; and the local→UTC conversion is inconsistent (`_compute_datetimes` resolves *company-calendar-tz → user-tz → UTC*, but `_inverse_datetime_*` uses *user-tz → UTC* only — they disagree, and fall back to UTC when unset).
|
||||
3. **No revenue capture at booking:** the booking creates a task but no priced order, even though every service call has a defined call-out fee.
|
||||
|
||||
**Goal:** a fast, polished **"Book a Service"** wizard that, from one screen, (a) captures the client — including brand-new clients inline, (b) books the technician task, (c) prices the call-out from the rate card, and (d) auto-creates a **draft repair Sale Order**. Every service call becomes a revenue-tracked order. Works in dark + light.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope
|
||||
|
||||
**In:** OWL booking wizard (complete design freedom); inline new-client create (name/phone/email/address); rate-card product catalog; service-type → call-out pricing; auto draft repair SO (call-out line + auto per-km); live on-screen estimate; 12-hour AM/PM time entry; timezone-conversion fix; relaxation of the SO constraint.
|
||||
|
||||
**Out (phase 2):** deposit/payment capture; multi-technician labour auto-doubling; SMS gateway; maintenance/PM plans; full quote builder (estimated labour & parts written onto the SO at booking — for now the SO carries call-out + per-km only, labour/parts added as actuals).
|
||||
|
||||
---
|
||||
|
||||
## 3. Pricing model (Westin rate card)
|
||||
|
||||
> These values only **seed** the editable `fusion.service.rate` table (§6.1). After install, admins
|
||||
> change any price and add new rate types from the **Service Rates** menu — nothing here is hardcoded,
|
||||
> and the wizard reflects edits live.
|
||||
|
||||
### 3.1 Call-out fee matrix (the guaranteed charge; includes 30 min labour where noted)
|
||||
|
||||
| Category | Normal | Rush (+km) | After-Hours (+km) |
|
||||
|---|---|---|---|
|
||||
| **Standard** | $95 | $120 | $140 |
|
||||
| **Lift & Elevating** | $160 | **$185** ◆ | **$205** ◆ |
|
||||
|
||||
- ◆ **Suggested fills** (not on the printed card). Derived from the card's own surcharge deltas: Standard Rush = +$25, After-Hours = +$45 over base; same deltas applied to the Lift base ($160) → $185 / $205. *Owner to confirm.*
|
||||
- **Rush & After-Hours** add **$0.70/km × 2-way** (round trip), computed from the booking's travel distance.
|
||||
- **In-shop (any device):** no call-out fee; labour billed at $75/hr; no delivery.
|
||||
|
||||
### 3.2 Labour (hourly, pro-rated in 30-min increments; per technician)
|
||||
- On-site (Standard): **$85/hr**
|
||||
- In-shop: **$75/hr** (already exists as product `LABOR`, default_code `LABOR`)
|
||||
- Lift & Elevating on-site: **$110/hr**
|
||||
|
||||
### 3.3 Travel
|
||||
- Per-km surcharge: **$0.70/km × 2-way**
|
||||
|
||||
### 3.4 Delivery / Pickup
|
||||
| Item | Price |
|
||||
|---|---|
|
||||
| Local (within Brampton) | $35 |
|
||||
| Outside local area | $60 |
|
||||
| Rush pickup/delivery | $60 + $0.70/km ×2-way |
|
||||
| Lift-chair delivery & set-up | $120 |
|
||||
| Hospital-bed delivery & set-up | $120 |
|
||||
| Stairlift delivery & set-up | $300 |
|
||||
| Stairlift removal | $300 |
|
||||
|
||||
### 3.5 Footnote rules (from the card)
|
||||
- A Service Call is an appointment **outside** a Westin location, billed **once per request**, includes **30 min labour**; labour rates apply after.
|
||||
- Parts are **not** charged when covered under manufacturer warranty (→ "Under warranty" flag on the wizard).
|
||||
- Multiple technicians → labour applies **per technician** (phase-2 auto-double; for now informational).
|
||||
|
||||
---
|
||||
|
||||
## 4. UX — wizard layout
|
||||
|
||||
Single page (no multi-step), grouped cards, brand-gradient header, dark/light. Sections (see mockup v2):
|
||||
|
||||
- **Customer** — segmented `Existing customer | New client`. Existing = search by **phone / name / SO** → prefill. New = **name, phone, email, address (street/unit/buzz/city)** inline; contact find-or-created on save.
|
||||
- **Service & Pricing** — *device being serviced* (→ auto-suggests category: scooter/chair/bed → Standard; stairlift/lift → Lift & Elevating), *issue/symptom*, *service call type* (category × timing), and the resulting **call-out fee** readout.
|
||||
- **Schedule** — date, **12-hour AM/PM start picker**, duration → auto end ("Ends at 10:00 AM · local time"), technician + availability hint.
|
||||
- **Location** — **in-shop toggle** (drives pricing: no call-out, $75 labour, hides address), job address.
|
||||
- **Job details** — work description, parts to bring, **under-warranty** toggle, POD, send-confirmation, request-review.
|
||||
- **Estimate** (prominent strip) — *call-out + est. labour + per-km = total*; "a draft repair SO is created."
|
||||
- **Footer** — Cancel · **Book & Create SO**.
|
||||
|
||||
Behaviours: device→category auto-suggest (overridable); in-shop flips pricing & hides address + call-out; live estimate recomputes on every change; AM/PM picker stores local float hours.
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
**Complete UI freedom without duplicating backend logic:**
|
||||
|
||||
- **OWL client action** `fusion_tasks.service_booking` — renders the layout; loads reference data (technicians, device types, rate products, customer search) via standalone `rpc()` (`@web/core/network/rpc`). Registered in `registry.category("actions")`. Opened from a "Book a Service" button/menu/dashboard tile (`ir.actions.client`).
|
||||
- **One server method** `fusion.technician.task.action_book_from_wizard(payload)`:
|
||||
1. Resolve customer — search `res.partner` by email then phone; create if new (name/phone/email/address). For "existing", use the chosen partner/SO's partner.
|
||||
2. Compute **travel distance now** (Google Distance Matrix via the existing `_calculate_travel_time`/`_get_google_maps_api_key`) from the shop / previous task to the job — needed for the per-km line.
|
||||
3. Create a **draft `sale.order`** tagged as a repair (see §6) with the **call-out product line** + an **auto per-km line** (qty = round(distance_km × 2), product = per-km $0.70) when the service type is Rush/After-Hours.
|
||||
4. Create the `fusion.technician.task` linked to that SO (reuses existing model `create` + address-fill + travel-chain logic).
|
||||
5. Return `{task_id, order_id}` so the client action can open the task or close.
|
||||
- **SCSS** `fusion_tasks/static/src/scss/_service_booking_tokens.scss` + `service_booking.scss`, branching on `$o-webclient-color-scheme` (per repo rule), registered in `web.assets_backend` **and** `web.assets_web_dark`. Three-layer contrast tokens (page → card → field), explicit hex.
|
||||
|
||||
All validation/workflow/pricing stays server-side; the OWL component is presentation + a single submit call.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data model changes
|
||||
|
||||
### 6.1 New: editable rate table `fusion.service.rate` (the configurable pricing control)
|
||||
A dedicated model so admins manage **all** pricing from a **Service Rates** menu — no code to change a price or add a service type.
|
||||
|
||||
**Fields:** `name`; `code` (unique, e.g. `callout_standard_normal`, `callout_lift_rush`, `labour_onsite`, `labour_lift`, `per_km`, `delivery_local`); `rate_kind` (callout / labour / travel / delivery / other); `category` (standard / lift / na); `timing` (normal / rush / afterhours / na); `in_shop` (bool); `product_id` (the `product.product` used on the SO line — for description, tax, income account); `price` (Monetary — the **editable source of truth**); `unit` (fixed / per_hour / per_km); `adds_per_km` (bool); `included_labour_min` (int, e.g. 30); `active`; `sequence`; `currency_id`.
|
||||
|
||||
- **Seed** (`data/service_rate_data.xml`, `noupdate=1`): one row per §3 rate, each linked to a seeded `product.product` (type `service`, `sale_ok`, correct UoM — hour/km/unit, HST). `noupdate=1` means a later `-u` never overwrites admin price edits.
|
||||
- **Views/menu:** list + form under *Field Service → Configuration → Service Rates* (manager-only) — edit price, add/remove rows, toggle `active`.
|
||||
- **Products still exist** (SO lines + accounting need a product), but the **rate row's `price` is the source of truth** — the SO line takes `price_unit` from the rate, not the product's `list_price`. One place to edit.
|
||||
- The **wizard builds its service-type selector from the active `callout` rows**, so a new rate row appears in the wizard automatically.
|
||||
|
||||
### 6.2 `fusion_tasks` — `fusion.technician.task`
|
||||
- Make `_compute_datetimes` and `_inverse_datetime_start/_end` use **one** tz resolver (`_get_local_tz()` everywhere) so compute and inverse agree; document that local float hours ↔ UTC datetime is the single source of truth.
|
||||
- Time entry stays `time_start`/`time_end` floats (local); the **AM/PM presentation lives in the OWL wizard**; the existing `time_start_display` (12h) already covers list/kanban/calendar.
|
||||
|
||||
### 6.3 `fusion_claims` — `fusion.technician.task` + `sale.order`
|
||||
- **Relax** `_check_order_link`: no longer raise when there is no SO/PO — the wizard now auto-creates the SO, and in-shop/walk-in tasks may legitimately have none. (Keep the helper that auto-fills address from an SO when one *is* linked.)
|
||||
- Add `x_fc_service_call_type` (Selection: standard/lift × normal/rush/afterhours, + in_shop) on the task, set by the wizard, used to pick the call-out product and for reporting.
|
||||
- Add a **pricing resolver** that reads `fusion.service.rate`: `_get_callout_rate(category, timing, in_shop)` and `_get_rate(code)` (per-km, labour, delivery) + `_build_service_so(partner, rate, distance_km, ...)` that creates the SO + lines using each rate's `product_id` with `price_unit` taken from the rate row.
|
||||
- **Repair-SO identity:** boolean `x_fc_is_service_repair` on `sale.order` + an `crm.tag`/SO tag "Service Repair" so these orders are filterable; reuse the standard quotation flow.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pricing engine
|
||||
|
||||
- Reads the **`fusion.service.rate`** table (§6.1) — never hardcoded.
|
||||
- `_get_callout_rate(category, timing, in_shop)` → the matching active `callout` row (none if in-shop). Its `price` → the SO call-out line `price_unit`; its `product_id` → the line product.
|
||||
- **Per-km:** when the call-out row's `adds_per_km` is set, add a line from the `per_km` rate row, qty = `round(distance_km × 2)`, `price_unit` = that row's price.
|
||||
- **On-screen estimate (UI only, not written to SO):** `callout.price + max(0, duration − included_labour_min/60) × labour_rate + per-km`, where `labour_rate` is read from the `labour_*` rate rows (in-shop / on-site / lift).
|
||||
|
||||
---
|
||||
|
||||
## 8. Timezone fix (folds in the audit finding)
|
||||
|
||||
Single resolver `_get_local_tz()` (company resource-calendar tz → user tz → UTC) used by **both** `_compute_datetimes` and the inverses, eliminating the compute/inverse mismatch and the silent UTC fallback. Booking writes local float hours; datetime_start/end (UTC) recompute consistently for the calendar/sync.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open decisions (defaults chosen — confirm at review)
|
||||
|
||||
| # | Decision | Default |
|
||||
|---|---|---|
|
||||
| 1 | Lift Rush / After-Hours call-out | **$185 / $205** (parallel surcharge) |
|
||||
| 2 | In-shop pricing | no call-out, labour @ $75/hr, no delivery |
|
||||
| 3 | Repair-SO identity | boolean `x_fc_is_service_repair` + SO tag "Service Repair" |
|
||||
| 4 | Estimate labour | on-screen guide only; SO = call-out + per-km; labour/parts as actuals |
|
||||
| 5 | Per-km distance basis | Distance Matrix, shop/previous-task → job, ×2-way |
|
||||
| 6 | Rate configurability | editable `fusion.service.rate` table + **Service Rates** menu; the card only seeds it, admin-owned thereafter |
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing & rollout
|
||||
|
||||
- Enterprise-only stack (these modules need `fusion_claims`/`fusion_portal` deps) → **verify on a Westin clone**, not local Community.
|
||||
- Seed products + taxes; smoke-test: new-client booking → contact + task + draft SO created with the right call-out (+ per-km on rush/after-hours); existing-customer booking; in-shop (no call-out); tz correctness on the task + calendar; dark + light bundles.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build sequence (for the implementation plan)
|
||||
|
||||
1. **`fusion.service.rate` model** + seeded rows + products + taxes + *Service Rates* menu/views.
|
||||
2. **TZ fix** + confirm AM/PM round-trips (time floats).
|
||||
3. **Constraint relax** + `x_fc_service_call_type` + pricing resolver + `_build_service_so` + `action_book_from_wizard` (server).
|
||||
4. **OWL wizard** client action + SCSS (dark/light).
|
||||
5. **Entry point** (button/menu/tile) + `ir.actions.client`.
|
||||
6. **Clone-verify** end-to-end.
|
||||
@@ -247,3 +247,24 @@ class FusionBillingService(models.Model):
|
||||
sub.action_confirm()
|
||||
return {'status': 'ok', 'subscription_id': sub.id,
|
||||
'subscription_state': sub.subscription_state}
|
||||
|
||||
def _api_cancel_subscription(self, external_ref):
|
||||
"""Cancel (close) the subscription identified by ``external_ref``.
|
||||
|
||||
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
|
||||
exist, be a subscription, and belong to a customer THIS service is linked
|
||||
to. Idempotent — closing an already-churned subscription returns ok.
|
||||
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if external_ref in (None, ''):
|
||||
return {'status': 'error', 'error': 'subscription id required'}
|
||||
sub = self._fc_resolve_subscription(external_ref)
|
||||
linked_partners = self.account_link_ids.mapped('partner_id')
|
||||
if not sub.exists() or not sub.is_subscription \
|
||||
or sub.partner_id not in linked_partners:
|
||||
return {'status': 'error', 'error': 'unknown subscription'}
|
||||
if sub.subscription_state != '6_churn':
|
||||
sub.set_close()
|
||||
return {'status': 'ok', 'subscription_id': sub.id,
|
||||
'subscription_state': sub.subscription_state}
|
||||
|
||||
@@ -6,3 +6,4 @@ from . import test_webhook
|
||||
from . import test_importer
|
||||
from . import test_reconciliation
|
||||
from . import test_invoice_ledger
|
||||
from . import test_subscription_cancel
|
||||
|
||||
@@ -18,11 +18,26 @@ def _inv_fixture():
|
||||
}]
|
||||
|
||||
|
||||
def _fc_ensure_ca_billing_env(env):
|
||||
"""Prod (`nexamain`) is a fully-configured Canadian company; a bare test DB is not.
|
||||
Give it the two things the ledger needs: an active CAD currency and a 13% sale tax
|
||||
matching invoice.ledger.wizard._fc_tax_for (type_tax_use=sale, percent, amount=13)."""
|
||||
cad = env.ref('base.CAD')
|
||||
if not cad.active:
|
||||
cad.sudo().write({'active': True})
|
||||
Tax = env['account.tax'].sudo()
|
||||
if not Tax.search([('type_tax_use', '=', 'sale'),
|
||||
('amount_type', '=', 'percent'), ('amount', '=', 13.0)], limit=1):
|
||||
Tax.create({'name': 'HST 13%', 'type_tax_use': 'sale',
|
||||
'amount_type': 'percent', 'amount': 13.0})
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerFamily(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_fc_ensure_ca_billing_env(self.env)
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_family_classification(self):
|
||||
@@ -47,6 +62,7 @@ class TestLedgerTax(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_fc_ensure_ca_billing_env(self.env)
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
||||
@@ -68,6 +84,7 @@ class TestLedgerIngest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_fc_ensure_ca_billing_env(self.env)
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.Move = self.env['account.move']
|
||||
|
||||
@@ -174,6 +191,7 @@ class TestLedgerVerifiedSync(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_fc_ensure_ca_billing_env(self.env)
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.Move = self.env['account.move']
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
54
fusion_centralize_billing/tests/test_subscription_cancel.py
Normal file
54
fusion_centralize_billing/tests/test_subscription_cancel.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSubscriptionCancel(TransactionCase):
|
||||
|
||||
def _service(self, code, name):
|
||||
Svc = self.env['fusion.billing.service'].sudo()
|
||||
return Svc.search([('code', '=', code)], limit=1) or Svc.create(
|
||||
{'name': name, 'code': code})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.product = self.env['product.product'].sudo().create(
|
||||
{'name': 'NexaCloud Plan', 'type': 'service',
|
||||
'recurring_invoice': True, 'list_price': 49.0})
|
||||
self.svc_a = self._service('nexacloud', 'NexaCloud')
|
||||
self.svc_b = self._service('other_app', 'Other App')
|
||||
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
|
||||
res = self.svc_a._api_create_subscription({
|
||||
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
|
||||
self.sub = self.env['sale.order'].browse(res['subscription_id'])
|
||||
|
||||
def test_cancel_closes_subscription(self):
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_is_idempotent(self):
|
||||
self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_unknown_subscription_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('999999999')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
|
||||
def test_cancel_cross_service_rejected(self):
|
||||
# svc_b is not linked to the customer that owns self.sub
|
||||
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
|
||||
def test_cancel_missing_id_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
@@ -9,7 +9,8 @@ class TestRatingCron(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
Metric = self.env['fusion.billing.metric'].sudo()
|
||||
self.metric = Metric.search([('code', '=', 'cpu_seconds')], limit=1) or Metric.create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
@@ -67,7 +68,8 @@ class TestUsageIngestion(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
Metric = self.env['fusion.billing.metric'].sudo()
|
||||
self.metric = Metric.search([('code', '=', 'cpu_seconds')], limit=1) or Metric.create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
|
||||
@@ -13,11 +13,17 @@ class TestWebhookEngine(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = self.env['fusion.billing.service'].sudo().create({
|
||||
Service = self.env['fusion.billing.service'].sudo()
|
||||
vals = {
|
||||
'name': 'NexaCloud', 'code': 'nexacloud',
|
||||
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
|
||||
'webhook_secret': 'whsec_test',
|
||||
})
|
||||
}
|
||||
self.service = Service.search([('code', '=', 'nexacloud')], limit=1)
|
||||
if self.service:
|
||||
self.service.write(vals)
|
||||
else:
|
||||
self.service = Service.create(vals)
|
||||
self.Webhook = self.env['fusion.billing.webhook'].sudo()
|
||||
|
||||
def test_enqueue_signs_payload(self):
|
||||
|
||||
@@ -3104,3 +3104,72 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
|
||||
- Add new gotchas in the right format
|
||||
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
|
||||
- Know the deployment fact that fusion_portal is always co-installed
|
||||
|
||||
## 47. Service Booking wizard — two CSS gotchas (client action, `static/src/scss/service_booking.scss` + `xml/service_booking.xml`)
|
||||
|
||||
The OWL "Book a Service" wizard renders inside the Odoo **backend** (`web.assets_backend`),
|
||||
so the full Bootstrap 5 + Odoo stylesheet is live around it. Two non-obvious traps bit this
|
||||
wizard and were fixed in **v19.0.9.6.0** (a first, blind CSS pass in 19.0.9.5.0 did not fix
|
||||
the real cause — verify with a render, not by eye):
|
||||
|
||||
1. **Never reuse Bootstrap layout class names inside a backend component — namespace them.**
|
||||
The wizard originally used `row` / `card` / `grid` / `btn`. Scoping the rules under
|
||||
`.o_service_booking` does **not** stop Bootstrap's *global* `.row{display:flex;
|
||||
margin-left/right:calc(-.5*32px)}`, `.card{display:flex;flex-direction:column}`,
|
||||
`.grid{grid-template-rows:…}`, `.btn{…}` from also applying (they win for any property
|
||||
the scoped rule doesn't set). Measured live: every wizard `.row` computed
|
||||
`display:flex; margin-left:-16px; margin-right:-16px` — the negative gutter pulled fields
|
||||
to the card edges and flexed label+input pairs. **Fix:** all custom layout classes are
|
||||
`sb-*` (`sb-row`/`sb-card`/`sb-grid`/`sb-btn`). Keep that prefix for any new wizard class
|
||||
that could collide with Bootstrap (`col`, `container`, `form-*`, `badge`, …).
|
||||
|
||||
2. **A nested `@media` block must come AFTER the base rule it overrides (equal specificity).**
|
||||
SCSS preserves source order. The responsive `@media (max-width:560px){ .two,.three{
|
||||
grid-template-columns:1fr } … }` was nested high in the file, *before* the base
|
||||
`.two{grid-template-columns:1fr 1fr}` / `.three` / `.timepick` rules. Both selectors have
|
||||
the same specificity, so the later base rule overrode the media query — it was **dead**,
|
||||
and the inner field-grids never collapsed to one column on a phone (fields crammed 2–3
|
||||
across). `matchMedia('(max-width:560px)')` returned true while the columns stayed 2-up —
|
||||
the tell that it's a cascade-order bug, not a media-match bug. **Fix:** all responsive
|
||||
`@media` overrides live at the **end** of the `.o_service_booking { … }` block.
|
||||
|
||||
**How it was verified (do this, don't eyeball):** pull the live compiled bundle from prod
|
||||
(`env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` returns `ir.attachment`
|
||||
record(s) in Odoo 19 — read `.raw`, not a string), render the wizard markup against it with
|
||||
the real web-client height/scroll chain (`html,body{height:100%}` → `.o_web_client` flex
|
||||
column → 46px navbar + `.o_action_manager{flex:1;min-height:0}` so the wizard's
|
||||
`height:100%;overflow:auto` scrolls) at 320/390/768/1280, and read computed
|
||||
`grid-template-columns` / `margin-left` / `display`. A standalone vanilla-Bootstrap repro is
|
||||
**not** faithful — it rendered fine and falsely cleared the bug.
|
||||
|
||||
## 48. Service Booking wizard — dynamic fields (live client search + address autocomplete), v19.0.9.7.0
|
||||
|
||||
The wizard is a **client action** (registered OWL component), not a form view, so two
|
||||
"type-ahead" features had to be built into the component itself:
|
||||
|
||||
1. **Live client search** ("Existing customer" box). Endpoint
|
||||
`/fusion_claims/service_booking/search_customers` (jsonrpc, auth=user) searches
|
||||
`res.partner` and resolves a typed SO number to its partner; the JS debounces (250 ms),
|
||||
shows a `.sb-cust-results` dropdown, and `pickCustomer()` sets `state.partnerId` + fills the
|
||||
contact fields. The backend (`action_book_from_wizard`) already consumes `partner_id` for
|
||||
`cust_mode='existing'`, so picking links the existing contact.
|
||||
**GOTCHA (cost a near-miss): `res.partner` has NO `mobile` field in Odoo 19** — a domain
|
||||
leaf `('mobile','ilike',q)` raises `ValueError: Invalid field res.partner.mobile`, and
|
||||
because the controller's `except` swallows it the search silently returns nothing (looks
|
||||
exactly like "search not working"). Build the OR domain only over fields present in
|
||||
`Partner._fields` (`name`/`phone`/`email`); same for reading `p.mobile`. Verify any new
|
||||
partner-field reference against `_fields` before shipping.
|
||||
|
||||
2. **Address autocomplete.** `google_address_autocomplete.js` patches `FormController` only,
|
||||
so it does **not** reach this client action. The component loads Google Places itself
|
||||
(key from ICP `fusion_claims.google_maps_api_key`), attaches via `useRef('root')` +
|
||||
`onMounted`/`onPatched` to every `input.sb-addr-input`, and writes
|
||||
`street`/`city`/`lat`/`lng` straight into reactive `state` (no DOM hacks — we're inside the
|
||||
component). Re-attach on patch is guarded by an `_sbAc` flag per input and an `_addrStarted`
|
||||
/`_addrNoKey` gate so a missing key just degrades to manual entry and can never break render
|
||||
(both lifecycle calls are `.catch(()=>{})`).
|
||||
|
||||
**Verifying the search without a browser:** the endpoint logic is plain ORM — smoke it in an
|
||||
`odoo shell` against real data (`Partner.search(<built domain>, limit=8)`); a vanilla repro of
|
||||
the `.sb-cust-results` dropdown markup + compiled CSS confirms the UI. Address autocomplete
|
||||
needs the live key + Google Maps, so it can only be confirmed in a real browser session.
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import controllers
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Claims',
|
||||
'version': '19.0.9.2.0',
|
||||
'version': '19.0.9.7.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
@@ -98,9 +98,13 @@
|
||||
'data/ir_cron_data.xml',
|
||||
'data/ir_actions_server_data.xml',
|
||||
'data/product_labor_data.xml',
|
||||
'data/service_rate_products.xml',
|
||||
'data/service_rate_data.xml',
|
||||
'wizard/status_change_reason_wizard_views.xml',
|
||||
'views/res_company_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/service_rate_views.xml',
|
||||
'views/service_booking_action.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/account_journal_views.xml',
|
||||
@@ -181,12 +185,20 @@
|
||||
# Dashboard OWL countdown widget
|
||||
'fusion_claims/static/src/js/fc_posting_countdown.js',
|
||||
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
|
||||
# Service Booking wizard (client action): tokens MUST load before
|
||||
# the component scss so the --sb-* vars resolve.
|
||||
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
|
||||
'fusion_claims/static/src/scss/service_booking.scss',
|
||||
'fusion_claims/static/src/js/service_booking/service_booking.js',
|
||||
'fusion_claims/static/src/xml/service_booking.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
# Dark bundle recompiles the same SCSS with the dark
|
||||
# $o-webclient-color-scheme default so tokens branch correctly.
|
||||
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
|
||||
'fusion_claims/static/src/scss/fc_dashboard.scss',
|
||||
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
|
||||
'fusion_claims/static/src/scss/service_booking.scss',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/icon.png'],
|
||||
|
||||
1
fusion_claims/controllers/__init__.py
Normal file
1
fusion_claims/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import service_booking
|
||||
76
fusion_claims/controllers/service_booking.py
Normal file
76
fusion_claims/controllers/service_booking.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class ServiceBookingController(http.Controller):
|
||||
|
||||
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
|
||||
def refdata(self, **kw):
|
||||
env = request.env
|
||||
Users = env['res.users']
|
||||
techs = Users.search([('x_fc_is_field_staff', '=', True)]) \
|
||||
if 'x_fc_is_field_staff' in Users._fields else Users.search([])
|
||||
Rate = env['fusion.service.rate']
|
||||
rates = Rate.search([('rate_kind', '=', 'callout'), ('active', '=', True)])
|
||||
per_km = Rate.get_rate('per_km')
|
||||
|
||||
def labour(code):
|
||||
r = Rate.get_rate(code)
|
||||
return r.price if r else 0.0
|
||||
|
||||
return {
|
||||
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
|
||||
'callout_rates': [{
|
||||
'code': r.code, 'category': r.category, 'timing': r.timing,
|
||||
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
|
||||
} for r in rates],
|
||||
'per_km': per_km.price if per_km else 0.70,
|
||||
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
|
||||
'lift': labour('labour_lift')},
|
||||
}
|
||||
|
||||
@http.route('/fusion_claims/service_booking/search_customers', type='jsonrpc', auth='user')
|
||||
def search_customers(self, query=None, **kw):
|
||||
"""Live customer lookup for the booking wizard's 'Existing customer' box.
|
||||
Matches res.partner by name / phone / mobile / email, and also resolves a
|
||||
typed sale-order reference to its customer. Returns up to 8 light dicts."""
|
||||
q = (query or '').strip()
|
||||
if len(q) < 2:
|
||||
return {'results': []}
|
||||
env = request.env
|
||||
Partner = env['res.partner'].sudo()
|
||||
# Build the OR domain only over fields that actually exist on this DB —
|
||||
# res.partner.mobile is NOT present in Odoo 19, so referencing it raises
|
||||
# ValueError and the whole lookup silently returns nothing.
|
||||
has_mobile = 'mobile' in Partner._fields
|
||||
search_fields = [f for f in ('name', 'phone', 'email', 'mobile')
|
||||
if f in Partner._fields]
|
||||
leaves = [(f, 'ilike', q) for f in search_fields]
|
||||
domain = leaves[:1]
|
||||
for leaf in leaves[1:]:
|
||||
domain = ['|'] + domain + [leaf]
|
||||
partners = Partner.search(domain, limit=8, order='write_date desc')
|
||||
# also resolve an SO number -> its partner (the hint promises "name or SO")
|
||||
if len(q) >= 3 and len(partners) < 8 and 'sale.order' in env:
|
||||
sos = env['sale.order'].sudo().search([('name', 'ilike', q)], limit=5)
|
||||
partners = (partners | sos.mapped('partner_id'))[:8]
|
||||
results = []
|
||||
for p in partners:
|
||||
phone = p.phone or (p.mobile if has_mobile else '') or ''
|
||||
results.append({
|
||||
'id': p.id,
|
||||
'name': p.name or '',
|
||||
'phone': phone,
|
||||
'email': p.email or '',
|
||||
'street': p.street or '',
|
||||
'city': p.city or '',
|
||||
})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
|
||||
def submit(self, payload=None, **kw):
|
||||
try:
|
||||
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
108
fusion_claims/data/service_rate_data.xml
Normal file
108
fusion_claims/data/service_rate_data.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- CALL-OUTS -->
|
||||
<record id="rate_callout_standard_normal" model="fusion.service.rate">
|
||||
<field name="name">Standard Service Call</field>
|
||||
<field name="code">callout_standard_normal</field>
|
||||
<field name="rate_kind">callout</field><field name="category">standard</field>
|
||||
<field name="timing">normal</field><field name="unit">fixed</field>
|
||||
<field name="included_labour_min">30</field><field name="price">95.0</field>
|
||||
<field name="product_id" ref="product_callout_standard_normal"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="rate_callout_standard_rush" model="fusion.service.rate">
|
||||
<field name="name">Rush Service Call (Standard)</field>
|
||||
<field name="code">callout_standard_rush</field>
|
||||
<field name="rate_kind">callout</field><field name="category">standard</field>
|
||||
<field name="timing">rush</field><field name="unit">fixed</field>
|
||||
<field name="adds_per_km" eval="True"/><field name="price">120.0</field>
|
||||
<field name="product_id" ref="product_callout_standard_rush"/>
|
||||
<field name="sequence">11</field>
|
||||
</record>
|
||||
<record id="rate_callout_standard_afterhours" model="fusion.service.rate">
|
||||
<field name="name">After-Hours Service Call (Standard)</field>
|
||||
<field name="code">callout_standard_afterhours</field>
|
||||
<field name="rate_kind">callout</field><field name="category">standard</field>
|
||||
<field name="timing">afterhours</field><field name="unit">fixed</field>
|
||||
<field name="adds_per_km" eval="True"/><field name="price">140.0</field>
|
||||
<field name="product_id" ref="product_callout_standard_afterhours"/>
|
||||
<field name="sequence">12</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_normal" model="fusion.service.rate">
|
||||
<field name="name">Lift & Elevating Service Call</field>
|
||||
<field name="code">callout_lift_normal</field>
|
||||
<field name="rate_kind">callout</field><field name="category">lift</field>
|
||||
<field name="timing">normal</field><field name="unit">fixed</field>
|
||||
<field name="included_labour_min">30</field><field name="price">160.0</field>
|
||||
<field name="product_id" ref="product_callout_lift_normal"/>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_rush" model="fusion.service.rate">
|
||||
<field name="name">Lift & Elevating Rush Call</field>
|
||||
<field name="code">callout_lift_rush</field>
|
||||
<field name="rate_kind">callout</field><field name="category">lift</field>
|
||||
<field name="timing">rush</field><field name="unit">fixed</field>
|
||||
<field name="adds_per_km" eval="True"/><field name="price">185.0</field>
|
||||
<field name="product_id" ref="product_callout_lift_rush"/>
|
||||
<field name="sequence">21</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_afterhours" model="fusion.service.rate">
|
||||
<field name="name">Lift & Elevating After-Hours Call</field>
|
||||
<field name="code">callout_lift_afterhours</field>
|
||||
<field name="rate_kind">callout</field><field name="category">lift</field>
|
||||
<field name="timing">afterhours</field><field name="unit">fixed</field>
|
||||
<field name="adds_per_km" eval="True"/><field name="price">205.0</field>
|
||||
<field name="product_id" ref="product_callout_lift_afterhours"/>
|
||||
<field name="sequence">22</field>
|
||||
</record>
|
||||
|
||||
<!-- LABOUR -->
|
||||
<record id="rate_labour_onsite" model="fusion.service.rate">
|
||||
<field name="name">Labour — On-Site</field><field name="code">labour_onsite</field>
|
||||
<field name="rate_kind">labour</field><field name="category">standard</field>
|
||||
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">85.0</field>
|
||||
<field name="product_id" ref="product_labour_onsite"/><field name="sequence">30</field>
|
||||
</record>
|
||||
<record id="rate_labour_lift" model="fusion.service.rate">
|
||||
<field name="name">Labour — Lift & Elevating</field><field name="code">labour_lift</field>
|
||||
<field name="rate_kind">labour</field><field name="category">lift</field>
|
||||
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">110.0</field>
|
||||
<field name="product_id" ref="product_labour_lift"/><field name="sequence">31</field>
|
||||
</record>
|
||||
<record id="rate_labour_inshop" model="fusion.service.rate">
|
||||
<field name="name">Labour — In-Shop</field><field name="code">labour_inshop</field>
|
||||
<field name="rate_kind">labour</field><field name="category">na</field><field name="in_shop" eval="True"/>
|
||||
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">75.0</field>
|
||||
<field name="product_id" ref="product_labour_inshop"/><field name="sequence">32</field>
|
||||
</record>
|
||||
|
||||
<!-- TRAVEL -->
|
||||
<record id="rate_per_km" model="fusion.service.rate">
|
||||
<field name="name">Travel — per km (2-way)</field><field name="code">per_km</field>
|
||||
<field name="rate_kind">travel</field><field name="category">na</field>
|
||||
<field name="timing">na</field><field name="unit">per_km</field><field name="price">0.70</field>
|
||||
<field name="product_id" ref="product_per_km"/><field name="sequence">40</field>
|
||||
</record>
|
||||
|
||||
<!-- DELIVERY / PICKUP -->
|
||||
<record id="rate_delivery_local" model="fusion.service.rate">
|
||||
<field name="name">Delivery / Pickup — Local</field><field name="code">delivery_local</field>
|
||||
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
|
||||
<field name="unit">fixed</field><field name="price">35.0</field>
|
||||
<field name="product_id" ref="product_delivery_local"/><field name="sequence">50</field>
|
||||
</record>
|
||||
<record id="rate_delivery_outside" model="fusion.service.rate">
|
||||
<field name="name">Delivery / Pickup — Outside Local Area</field><field name="code">delivery_outside</field>
|
||||
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
|
||||
<field name="unit">fixed</field><field name="price">60.0</field>
|
||||
<field name="product_id" ref="product_delivery_outside"/><field name="sequence">51</field>
|
||||
</record>
|
||||
<record id="rate_setup_stairlift" model="fusion.service.rate">
|
||||
<field name="name">Stairlift — Delivery & Set-up</field><field name="code">setup_stairlift</field>
|
||||
<field name="rate_kind">delivery</field><field name="category">lift</field><field name="timing">na</field>
|
||||
<field name="unit">fixed</field><field name="price">300.0</field>
|
||||
<field name="product_id" ref="product_setup_stairlift"/><field name="sequence">52</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
138
fusion_claims/data/service_rate_products.xml
Normal file
138
fusion_claims/data/service_rate_products.xml
Normal file
@@ -0,0 +1,138 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Call-outs (unit) -->
|
||||
<record id="product_callout_standard_normal" model="product.product">
|
||||
<field name="name">Service Call — Standard</field>
|
||||
<field name="default_code">SVC-STD</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">95.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_standard_rush" model="product.product">
|
||||
<field name="name">Service Call — Standard Rush</field>
|
||||
<field name="default_code">SVC-STD-RUSH</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">120.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_standard_afterhours" model="product.product">
|
||||
<field name="name">Service Call — Standard After-Hours</field>
|
||||
<field name="default_code">SVC-STD-AH</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">140.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_lift_normal" model="product.product">
|
||||
<field name="name">Service Call — Lift & Elevating</field>
|
||||
<field name="default_code">SVC-LIFT</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">160.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_lift_rush" model="product.product">
|
||||
<field name="name">Service Call — Lift & Elevating Rush</field>
|
||||
<field name="default_code">SVC-LIFT-RUSH</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">185.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_callout_lift_afterhours" model="product.product">
|
||||
<field name="name">Service Call — Lift & Elevating After-Hours</field>
|
||||
<field name="default_code">SVC-LIFT-AH</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">205.00</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Labour (hour) -->
|
||||
<record id="product_labour_onsite" model="product.product">
|
||||
<field name="name">Labour — On-Site</field>
|
||||
<field name="default_code">LAB-ONSITE</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">85.00</field>
|
||||
<field name="uom_id" ref="uom.product_uom_hour"/>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_labour_lift" model="product.product">
|
||||
<field name="name">Labour — Lift & Elevating</field>
|
||||
<field name="default_code">LAB-LIFT</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">110.00</field>
|
||||
<field name="uom_id" ref="uom.product_uom_hour"/>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="product_labour_inshop" model="product.product">
|
||||
<field name="name">Labour — In-Shop</field>
|
||||
<field name="default_code">LAB-INSHOP</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">75.00</field>
|
||||
<field name="uom_id" ref="uom.product_uom_hour"/>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Travel (unit; qty = km x 2) -->
|
||||
<record id="product_per_km" model="product.product">
|
||||
<field name="name">Travel — per km (2-way)</field>
|
||||
<field name="default_code">SVC-KM</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">0.70</field>
|
||||
<field name="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Delivery / pickup (unit) -->
|
||||
<record id="product_delivery_local" model="product.product">
|
||||
<field name="name">Delivery / Pickup — Local</field>
|
||||
<field name="default_code">DEL-LOCAL</field>
|
||||
<field name="type">service</field><field name="list_price">35.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_delivery_outside" model="product.product">
|
||||
<field name="name">Delivery / Pickup — Outside Local Area</field>
|
||||
<field name="default_code">DEL-OUT</field>
|
||||
<field name="type">service</field><field name="list_price">60.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_delivery_rush" model="product.product">
|
||||
<field name="name">Rush Pickup / Delivery</field>
|
||||
<field name="default_code">DEL-RUSH</field>
|
||||
<field name="type">service</field><field name="list_price">60.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_setup_liftchair" model="product.product">
|
||||
<field name="name">Lift Chair — Delivery & Set-up</field>
|
||||
<field name="default_code">SETUP-LIFTCHAIR</field>
|
||||
<field name="type">service</field><field name="list_price">120.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_setup_hospitalbed" model="product.product">
|
||||
<field name="name">Hospital Bed — Delivery & Set-up</field>
|
||||
<field name="default_code">SETUP-BED</field>
|
||||
<field name="type">service</field><field name="list_price">120.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_setup_stairlift" model="product.product">
|
||||
<field name="name">Stairlift — Delivery & Set-up</field>
|
||||
<field name="default_code">SETUP-STAIRLIFT</field>
|
||||
<field name="type">service</field><field name="list_price">300.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_removal_stairlift" model="product.product">
|
||||
<field name="name">Stairlift — Removal</field>
|
||||
<field name="default_code">RMV-STAIRLIFT</field>
|
||||
<field name="type">service</field><field name="list_price">300.00</field>
|
||||
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -26,4 +26,5 @@ from . import ai_agent_ext
|
||||
from . import dashboard
|
||||
from . import res_partner
|
||||
from . import technician_task
|
||||
from . import page11_sign_request
|
||||
from . import page11_sign_request
|
||||
from . import service_rate
|
||||
@@ -338,6 +338,11 @@ class SaleOrder(models.Model):
|
||||
help='Type of sale for billing purposes. This field determines the workflow and billing rules.',
|
||||
)
|
||||
|
||||
x_fc_is_service_repair = fields.Boolean(
|
||||
string='Service Repair', copy=False,
|
||||
help='Auto-created from the technician service booking wizard.',
|
||||
)
|
||||
|
||||
x_fc_sale_type_locked = fields.Boolean(
|
||||
string='Sale Type Locked',
|
||||
compute='_compute_sale_type_locked',
|
||||
|
||||
81
fusion_claims/models/service_rate.py
Normal file
81
fusion_claims/models/service_rate.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionServiceRate(models.Model):
|
||||
_name = 'fusion.service.rate'
|
||||
_description = 'Field Service Rate'
|
||||
_order = 'sequence, rate_kind, category, timing'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(
|
||||
string='Code', required=True, index=True,
|
||||
help='Stable code used by the booking engine, e.g. callout_standard_normal, per_km.',
|
||||
)
|
||||
rate_kind = fields.Selection([
|
||||
('callout', 'Service Call-out'),
|
||||
('labour', 'Labour'),
|
||||
('travel', 'Travel / per-km'),
|
||||
('delivery', 'Delivery / Pickup'),
|
||||
('other', 'Other'),
|
||||
], string='Kind', required=True, default='callout')
|
||||
category = fields.Selection([
|
||||
('standard', 'Standard'),
|
||||
('lift', 'Lift & Elevating'),
|
||||
('na', 'N/A'),
|
||||
], string='Category', default='na')
|
||||
timing = fields.Selection([
|
||||
('normal', 'Normal'),
|
||||
('rush', 'Rush'),
|
||||
('afterhours', 'After-Hours'),
|
||||
('na', 'N/A'),
|
||||
], string='Timing', default='na')
|
||||
in_shop = fields.Boolean(string='In-Shop')
|
||||
product_id = fields.Many2one(
|
||||
'product.product', string='Invoice Product', required=True, ondelete='restrict',
|
||||
help='Product used on the sale-order line (description, tax, income account).',
|
||||
)
|
||||
price = fields.Monetary(
|
||||
string='Rate', required=True, currency_field='currency_id',
|
||||
help='Editable price used on the SO line and the on-screen estimate.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
unit = fields.Selection([
|
||||
('fixed', 'Flat'),
|
||||
('per_hour', 'Per hour'),
|
||||
('per_km', 'Per km'),
|
||||
], string='Unit', required=True, default='fixed')
|
||||
adds_per_km = fields.Boolean(
|
||||
string='Adds per-km travel',
|
||||
help='Call-outs billed as $X + per-km \xd7 2-way (rush / after-hours).',
|
||||
)
|
||||
included_labour_min = fields.Integer(
|
||||
string='Included labour (min)', default=0,
|
||||
help='Free labour minutes bundled into a service call (e.g. 30).',
|
||||
)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
_unique_code = models.Constraint(
|
||||
'UNIQUE(code)',
|
||||
'A service-rate code must be unique.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_callout(self, category, timing, in_shop=False):
|
||||
"""Active call-out rate for category+timing. Empty recordset when in-shop."""
|
||||
if in_shop:
|
||||
return self.browse()
|
||||
return self.search([
|
||||
('rate_kind', '=', 'callout'),
|
||||
('category', '=', category),
|
||||
('timing', '=', timing),
|
||||
], limit=1)
|
||||
|
||||
@api.model
|
||||
def get_rate(self, code):
|
||||
"""Active rate row by code (e.g. 'per_km', 'labour_onsite')."""
|
||||
return self.search([('code', '=', code)], limit=1)
|
||||
@@ -9,7 +9,7 @@ features to the base fusion.technician.task model.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.exceptions import UserError
|
||||
from markupsafe import Markup
|
||||
import logging
|
||||
|
||||
@@ -72,6 +72,15 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
default=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SERVICE BOOKING FIELDS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
x_fc_service_call_type = fields.Char(
|
||||
string='Service Call Type',
|
||||
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ONCHANGES
|
||||
# ------------------------------------------------------------------
|
||||
@@ -104,15 +113,9 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
|
||||
@api.constrains('sale_order_id', 'purchase_order_id')
|
||||
def _check_order_link(self):
|
||||
for task in self:
|
||||
if task.x_fc_sync_source:
|
||||
continue
|
||||
if task.task_type == 'ltc_visit':
|
||||
continue
|
||||
if not task.sale_order_id and not task.purchase_order_id:
|
||||
raise ValidationError(_(
|
||||
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
|
||||
))
|
||||
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
|
||||
# walk-in tasks may legitimately have none. No order link is required anymore.
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HOOK OVERRIDES
|
||||
@@ -395,6 +398,166 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
order.name, e,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SERVICE BOOKING HELPERS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
|
||||
"""Return a list of sale.order.line vals dicts for a service booking,
|
||||
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
|
||||
Rate = self.env['fusion.service.rate']
|
||||
lines = []
|
||||
callout = Rate.get_callout(category, timing, in_shop=in_shop)
|
||||
if not callout:
|
||||
return lines
|
||||
lines.append({
|
||||
'product_id': callout.product_id.id,
|
||||
'name': callout.name,
|
||||
'product_uom_qty': 1.0,
|
||||
'price_unit': callout.price,
|
||||
'name_is_km': False,
|
||||
})
|
||||
if callout.adds_per_km and distance_km:
|
||||
per_km = Rate.get_rate('per_km')
|
||||
if per_km:
|
||||
lines.append({
|
||||
'product_id': per_km.product_id.id,
|
||||
'name': '%s — %.1f km \xd7 2-way' % (per_km.name, distance_km),
|
||||
'product_uom_qty': round(distance_km * 2.0, 1),
|
||||
'price_unit': per_km.price,
|
||||
'name_is_km': True,
|
||||
})
|
||||
return lines
|
||||
|
||||
@api.model
|
||||
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
|
||||
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines.
|
||||
|
||||
Repair-SO identity is the x_fc_is_service_repair boolean (no crm.tag: fusion_claims
|
||||
has no crm dependency). x_fc_sale_type is intentionally left blank — a service repair
|
||||
is not one of the ADP/ODSP funder workflows, and the draft is editable afterwards.
|
||||
"""
|
||||
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
|
||||
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
|
||||
so_vals = {
|
||||
'partner_id': partner.id,
|
||||
'x_fc_is_service_repair': True,
|
||||
'order_line': order_lines,
|
||||
}
|
||||
return self.env['sale.order'].create(so_vals)
|
||||
|
||||
def _service_travel_origin(self):
|
||||
"""Return (lat, lng) of the technician's day-start location, to be used
|
||||
as the ORIGIN for the per-km travel calculation. NEVER returns the job's
|
||||
own address (that would give origin == destination == 0 km).
|
||||
|
||||
Fallback chain (all read-only — no geocoding API calls here):
|
||||
1. The technician's personal start address cached coords
|
||||
(res.users.partner_id.x_fc_start_address_lat/_lng — populated when
|
||||
the start address is saved, see fusion_tasks/models/res_partner.py).
|
||||
2. The company HQ start address cached coords, keyed off the
|
||||
``fusion_claims.technician_start_address`` ICP and cached by
|
||||
fusion_tasks under ``fusion_tasks.hq_coords:<address>``.
|
||||
3. (0.0, 0.0) — the correct graceful fallback. _calculate_travel_time
|
||||
guards on a falsy origin and simply returns False (→ no per-km line).
|
||||
|
||||
Geocoding is deliberately NOT performed here: a freshly typed new-client
|
||||
job address usually has no geocoded destination anyway, so distance is
|
||||
expected to be 0 in v1. We only avoid passing a WRONG origin.
|
||||
"""
|
||||
self.ensure_one()
|
||||
tech = self.technician_id
|
||||
if tech:
|
||||
partner = tech.partner_id
|
||||
if partner and partner.x_fc_start_address_lat and partner.x_fc_start_address_lng:
|
||||
return partner.x_fc_start_address_lat, partner.x_fc_start_address_lng
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
hq_addr = (ICP.get_param('fusion_claims.technician_start_address', '') or '').strip()
|
||||
if hq_addr:
|
||||
cached = ICP.get_param('fusion_tasks.hq_coords:%s' % hq_addr, '')
|
||||
if cached and ',' in cached:
|
||||
try:
|
||||
lat_s, lng_s = cached.split(',', 1)
|
||||
return float(lat_s), float(lng_s)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 0.0, 0.0
|
||||
|
||||
@api.model
|
||||
def action_book_from_wizard(self, payload):
|
||||
"""Single entry point for the OWL booking wizard:
|
||||
resolve/create contact -> create task -> compute distance -> build SO -> link.
|
||||
Returns {'task_id', 'order_id'}."""
|
||||
Partner = self.env['res.partner']
|
||||
cust = payload.get('customer') or {}
|
||||
|
||||
# 1. contact: new -> find-or-create (match email then phone); existing -> chosen partner
|
||||
if payload.get('cust_mode') == 'new':
|
||||
partner = False
|
||||
email = (cust.get('email') or '').strip()
|
||||
phone = (cust.get('phone') or '').strip()
|
||||
if email:
|
||||
partner = Partner.search([('email', '=ilike', email)], limit=1)
|
||||
if not partner and phone:
|
||||
partner = Partner.search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = Partner.create({
|
||||
'name': cust.get('name') or 'Walk-in',
|
||||
'phone': phone or False, 'email': email or False,
|
||||
'street': cust.get('street') or False, 'city': cust.get('city') or False,
|
||||
})
|
||||
else:
|
||||
partner = Partner.browse(int(payload['partner_id'])) if payload.get('partner_id') else Partner
|
||||
|
||||
category = payload.get('category', 'standard')
|
||||
timing = payload.get('timing', 'normal')
|
||||
in_shop = bool(payload.get('in_shop'))
|
||||
|
||||
# technician_id is REQUIRED on a task
|
||||
technician_id = payload.get('technician_id')
|
||||
if not technician_id:
|
||||
raise UserError(_("Please choose a technician for this service booking."))
|
||||
technician_id = int(technician_id)
|
||||
|
||||
# 2. task
|
||||
dur = float(payload.get('duration_hr') or 1.0)
|
||||
t_start = float(payload.get('time_start') or 9.0)
|
||||
task_vals = {
|
||||
'task_type': 'repair',
|
||||
'technician_id': technician_id,
|
||||
'scheduled_date': payload.get('date'),
|
||||
'time_start': t_start,
|
||||
'time_end': t_start + dur,
|
||||
'duration_hours': dur,
|
||||
'is_in_store': in_shop,
|
||||
'x_fc_service_call_type': '%s_%s' % (category, timing),
|
||||
'description': payload.get('description') or payload.get('issue') or _('Service booking'),
|
||||
}
|
||||
if partner:
|
||||
task_vals['partner_id'] = partner.id
|
||||
task = self.create(task_vals)
|
||||
|
||||
# 3. per-km distance: only when the rate adds it AND we have a real origin + a
|
||||
# geocoded job destination. Origin is the technician's start, never the job.
|
||||
distance_km = 0.0
|
||||
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
|
||||
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
|
||||
origin_lat, origin_lng = task._service_travel_origin()
|
||||
if origin_lat and origin_lng:
|
||||
try:
|
||||
task._calculate_travel_time(origin_lat, origin_lng) # sets travel_distance_km
|
||||
distance_km = task.travel_distance_km or 0.0
|
||||
except Exception:
|
||||
distance_km = 0.0
|
||||
|
||||
# 4. draft repair SO + link back to the task
|
||||
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
|
||||
if order:
|
||||
task.sale_order_id = order.id
|
||||
return {'task_id': task.id, 'order_id': order.id if order else False}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VIEW ACTIONS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -63,4 +63,6 @@ access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,mod
|
||||
access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0
|
||||
access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1
|
||||
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
|
||||
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
|
||||
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
|
||||
access_fusion_service_rate_admin,fusion.service.rate.admin,model_fusion_service_rate,base.group_system,1,1,1,1
|
||||
|
||||
|
208
fusion_claims/static/src/js/service_booking/service_booking.js
Normal file
208
fusion_claims/static/src/js/service_booking/service_booking.js
Normal file
@@ -0,0 +1,208 @@
|
||||
/** @odoo-module **/
|
||||
import { Component, useState, onWillStart, onMounted, onPatched, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ServiceBookingWizard extends Component {
|
||||
static template = "fusion_claims.ServiceBookingWizard";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.rootRef = useRef("root");
|
||||
this.state = useState({
|
||||
custMode: "existing",
|
||||
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "", lat: 0, lng: 0 },
|
||||
partnerId: false, soSearch: "", custResults: [], custSearching: false,
|
||||
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
|
||||
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
|
||||
warranty: false, pod: false, emailConfirm: true, googleReview: true,
|
||||
description: "", materials: "",
|
||||
technicians: [], calloutRates: [], perKm: 0.70,
|
||||
labour: { onsite: 85, inshop: 75, lift: 110 }, distanceKm: 13, saving: false,
|
||||
});
|
||||
onWillStart(async () => {
|
||||
const r = await rpc("/fusion_claims/service_booking/refdata", {});
|
||||
Object.assign(this.state, {
|
||||
technicians: r.technicians || [],
|
||||
calloutRates: r.callout_rates || [],
|
||||
perKm: r.per_km ?? 0.70,
|
||||
labour: r.labour || this.state.labour,
|
||||
});
|
||||
});
|
||||
// Address autocomplete attaches after the DOM exists and re-attaches when
|
||||
// OWL re-renders (e.g. switching to "New client" reveals a second address
|
||||
// input). Fully guarded — a missing Maps key just means manual entry.
|
||||
onMounted(() => { this._initAddrAutocomplete().catch(() => {}); });
|
||||
onPatched(() => { this._initAddrAutocomplete().catch(() => {}); });
|
||||
}
|
||||
|
||||
// ---- live customer search (Existing customer box) ----
|
||||
onCustSearch(ev) {
|
||||
const q = ev.target.value || "";
|
||||
this.state.soSearch = q;
|
||||
this.state.partnerId = false; // typing again unlinks any picked contact
|
||||
clearTimeout(this._custTimer);
|
||||
const term = q.trim();
|
||||
if (term.length < 2) { this.state.custResults = []; this.state.custSearching = false; return; }
|
||||
this.state.custSearching = true;
|
||||
this._custTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await rpc("/fusion_claims/service_booking/search_customers", { query: term });
|
||||
this.state.custResults = r.results || [];
|
||||
} catch (e) {
|
||||
this.state.custResults = [];
|
||||
}
|
||||
this.state.custSearching = false;
|
||||
}, 250);
|
||||
}
|
||||
pickCustomer(c) {
|
||||
this.state.partnerId = c.id;
|
||||
this.state.customer.name = c.name || "";
|
||||
this.state.customer.phone = c.phone || "";
|
||||
this.state.customer.email = c.email || "";
|
||||
this.state.customer.street = c.street || "";
|
||||
this.state.customer.city = c.city || "";
|
||||
this.state.custResults = [];
|
||||
this.state.soSearch = c.name + (c.phone ? ` · ${c.phone}` : "");
|
||||
}
|
||||
|
||||
// ---- Google Places address autocomplete (wizard-local; the FormController
|
||||
// patch in google_address_autocomplete.js does NOT reach a client action) ----
|
||||
async _getMapsKey() {
|
||||
try {
|
||||
return await this.orm.call("ir.config_parameter", "get_param", ["fusion_claims.google_maps_api_key"]);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
_loadMaps(key) {
|
||||
if (window.google?.maps?.places) return Promise.resolve();
|
||||
if (window._sbMapsLoading) return window._sbMapsLoading;
|
||||
window._sbMapsLoading = new Promise((resolve, reject) => {
|
||||
window._sbMapsReady = () => resolve();
|
||||
const s = document.createElement("script");
|
||||
s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=places&callback=_sbMapsReady`;
|
||||
s.async = true; s.defer = true; s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return window._sbMapsLoading;
|
||||
}
|
||||
async _initAddrAutocomplete() {
|
||||
const root = this.rootRef.el;
|
||||
if (!root) return;
|
||||
if (!this._addrStarted) {
|
||||
if (!root.querySelector("input.sb-addr-input")) return; // nothing to bind yet
|
||||
this._addrStarted = true;
|
||||
const key = await this._getMapsKey();
|
||||
if (!key) { this._addrNoKey = true; return; }
|
||||
try { await this._loadMaps(key); } catch (e) { return; }
|
||||
}
|
||||
if (this._addrNoKey || !window.google?.maps?.places) return;
|
||||
// re-query after the await: OWL may have swapped inputs during loading
|
||||
root.querySelectorAll("input.sb-addr-input").forEach((inp) => {
|
||||
if (inp._sbAc) return;
|
||||
inp._sbAc = true;
|
||||
try {
|
||||
const ac = new google.maps.places.Autocomplete(inp, {
|
||||
componentRestrictions: { country: "ca" },
|
||||
types: ["address"],
|
||||
fields: ["address_components", "formatted_address", "geometry"],
|
||||
});
|
||||
ac.addListener("place_changed", () => {
|
||||
const place = ac.getPlace();
|
||||
if (!place || !place.address_components) return;
|
||||
let city = "";
|
||||
for (const c of place.address_components) {
|
||||
if (c.types.includes("locality")) city = c.long_name;
|
||||
else if (c.types.includes("sublocality_level_1") && !city) city = c.long_name;
|
||||
}
|
||||
this.state.customer.street = place.formatted_address || inp.value;
|
||||
if (city) this.state.customer.city = city;
|
||||
if (place.geometry && place.geometry.location) {
|
||||
this.state.customer.lat = place.geometry.location.lat();
|
||||
this.state.customer.lng = place.geometry.location.lng();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
inp._sbAc = false; // allow a later retry if construction failed
|
||||
}
|
||||
});
|
||||
}
|
||||
get callout() {
|
||||
if (this.state.inShop) return null;
|
||||
return this.state.calloutRates.find(
|
||||
r => r.category === this.state.category && r.timing === this.state.timing) || null;
|
||||
}
|
||||
get labourRate() {
|
||||
if (this.state.inShop) return this.state.labour.inshop;
|
||||
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
|
||||
}
|
||||
get estimate() {
|
||||
const c = this.callout;
|
||||
const callout = c ? c.price : 0;
|
||||
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
|
||||
const billHr = Math.max(0, this.state.durationHr - incl);
|
||||
const labour = billHr * this.labourRate;
|
||||
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
|
||||
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
|
||||
}
|
||||
get endLabel() {
|
||||
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
|
||||
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
|
||||
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
|
||||
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
|
||||
}
|
||||
fmt(n) { return (n || 0).toFixed(2); }
|
||||
onDevice(ev) {
|
||||
this.state.device = ev.target.value;
|
||||
this.state.category = ev.target.value === "lift" ? "lift" : "standard";
|
||||
}
|
||||
onCallType(ev) {
|
||||
const r = this.state.calloutRates.find(x => x.code === ev.target.value);
|
||||
if (r) { this.state.category = r.category; this.state.timing = r.timing; }
|
||||
}
|
||||
setCust(m) { this.state.custMode = m; }
|
||||
setAmpm(v) { this.state.ampm = v; }
|
||||
|
||||
toggleInShop() { this.state.inShop = !this.state.inShop; }
|
||||
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
|
||||
|
||||
async submit() {
|
||||
if (this.state.saving) return;
|
||||
const s = this.state;
|
||||
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
|
||||
this.notification.add("Client name and phone are required.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
if (!s.technicianId) {
|
||||
this.notification.add("Please choose a technician.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
s.saving = true;
|
||||
const payload = {
|
||||
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
|
||||
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
|
||||
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
|
||||
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
|
||||
email_confirm: s.emailConfirm, google_review: s.googleReview,
|
||||
description: s.description, materials: s.materials,
|
||||
};
|
||||
try {
|
||||
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
|
||||
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
|
||||
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window", res_model: "fusion.technician.task",
|
||||
res_id: res.task_id, views: [[false, "form"]], target: "current",
|
||||
});
|
||||
} catch (e) {
|
||||
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
|
||||
s.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);
|
||||
73
fusion_claims/static/src/scss/_service_booking_tokens.scss
Normal file
73
fusion_claims/static/src/scss/_service_booking_tokens.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
// Fusion Claims — Service Booking wizard design tokens.
|
||||
//
|
||||
// Per the repo dark-mode rule (CLAUDE.md "Dark Mode — Branch on
|
||||
// $o-webclient-color-scheme at SCSS Compile Time"): this file is compiled into
|
||||
// BOTH web.assets_backend (bright) and web.assets_web_dark (dark). We branch at
|
||||
// COMPILE TIME on $o-webclient-color-scheme and emit one --sb-* CSS custom
|
||||
// property per token, scoped to .o_service_booking. Do NOT use .o_dark_mode /
|
||||
// [data-bs-theme] / prefers-color-scheme — none fire reliably in Odoo 19.
|
||||
//
|
||||
// Values are copied verbatim from the mockup's :root (light) and
|
||||
// [data-theme="dark"] (dark) blocks — technician-booking-wizard.html.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// --- light values (mockup :root / [data-theme="light"]) ---
|
||||
$_page: #eef0f3;
|
||||
$_panel: #e6e9ed;
|
||||
$_card: #ffffff;
|
||||
$_border: #d8dadd;
|
||||
$_text: #1f2430;
|
||||
$_muted: #6b7280;
|
||||
$_faint: #9ca3af;
|
||||
$_field: #ffffff;
|
||||
$_field-border: #cfd3d8;
|
||||
$_field-focus: #3a8fb7;
|
||||
$_chip: #f1f4f7;
|
||||
$_accent: #2e7aad;
|
||||
$_accent-soft: #e8f2f8;
|
||||
$_ok: #16a34a;
|
||||
$_star: #f5b301;
|
||||
$_money: #0f7d4e;
|
||||
$_money-soft: #e7f6ee;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
// --- dark values (mockup [data-theme="dark"]) ---
|
||||
$_page: #14161b !global;
|
||||
$_panel: #1b1e24 !global;
|
||||
$_card: #22262d !global;
|
||||
$_border: #343a42 !global;
|
||||
$_text: #e7eaef !global;
|
||||
$_muted: #9aa3af !global;
|
||||
$_faint: #6b7480 !global;
|
||||
$_field: #1a1d23 !global;
|
||||
$_field-border: #3a4049 !global;
|
||||
$_field-focus: #4aa3cf !global;
|
||||
$_chip: #2a2f37 !global;
|
||||
$_accent: #3a8fb7 !global;
|
||||
$_accent-soft: #19303d !global;
|
||||
$_ok: #22c55e !global;
|
||||
$_star: #f5b301 !global;
|
||||
$_money: #34d27f !global;
|
||||
$_money-soft: #15281f !global;
|
||||
}
|
||||
|
||||
.o_service_booking {
|
||||
--sb-page: #{$_page};
|
||||
--sb-panel: #{$_panel};
|
||||
--sb-card: #{$_card};
|
||||
--sb-border: #{$_border};
|
||||
--sb-text: #{$_text};
|
||||
--sb-muted: #{$_muted};
|
||||
--sb-faint: #{$_faint};
|
||||
--sb-field: #{$_field};
|
||||
--sb-field-border: #{$_field-border};
|
||||
--sb-field-focus: #{$_field-focus};
|
||||
--sb-chip: #{$_chip};
|
||||
--sb-accent: #{$_accent};
|
||||
--sb-accent-soft: #{$_accent-soft};
|
||||
--sb-ok: #{$_ok};
|
||||
--sb-star: #{$_star};
|
||||
--sb-money: #{$_money};
|
||||
--sb-money-soft: #{$_money-soft};
|
||||
}
|
||||
329
fusion_claims/static/src/scss/service_booking.scss
Normal file
329
fusion_claims/static/src/scss/service_booking.scss
Normal file
@@ -0,0 +1,329 @@
|
||||
// Fusion Claims — Service Booking wizard component styles.
|
||||
//
|
||||
// Ported from the mockup (technician-booking-wizard.html) scoped under
|
||||
// .o_service_booking. The mockup's CSS custom properties (--page, --card, …)
|
||||
// are renamed mechanically to the --sb-* tokens emitted by
|
||||
// _service_booking_tokens.scss (which MUST load first in the bundle). The
|
||||
// manual .theme-btn dark toggle is dropped — Odoo serves the dark bundle.
|
||||
//
|
||||
// Surfaces use the explicit-hex tokens (three-layer contrast: page -> card ->
|
||||
// field), never var(--bs-*). color-mix() is used only in standalone
|
||||
// background / box-shadow properties — never inside a border shorthand (the
|
||||
// Odoo 19 SCSS compiler silently drops color-mix in border shorthands).
|
||||
|
||||
.o_service_booking {
|
||||
background: var(--sb-page);
|
||||
color: var(--sb-text);
|
||||
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
// Fill the action area and scroll INTERNALLY. min-height let the root grow
|
||||
// to its content height so the (clipping) action container never scrolled;
|
||||
// height:100% caps it so overflow:auto engages on small screens.
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.wrap { max-width: 1000px; margin: 24px auto; padding: 0 18px; }
|
||||
|
||||
.dialog {
|
||||
background: var(--sb-panel);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(16, 24, 40, .16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
|
||||
padding: 17px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
|
||||
h1 { font-size: 19px; font-weight: 700; margin: 0; }
|
||||
.sub { font-size: 12.5px; opacity: .9; margin-top: 2px; }
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 11px 24px;
|
||||
background: var(--sb-panel);
|
||||
border-bottom: 1px solid var(--sb-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.step {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--sb-faint);
|
||||
padding: 5px 13px;
|
||||
border-radius: 20px;
|
||||
background: var(--sb-chip);
|
||||
}
|
||||
.step.active { color: #fff; background: linear-gradient(135deg, #3a8fb7, #2e7aad); }
|
||||
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
|
||||
|
||||
.body { padding: 20px 24px 6px; }
|
||||
.sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.sb-card {
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 13px;
|
||||
padding: 16px 17px;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
|
||||
}
|
||||
.sb-card.span2 { grid-column: 1 / -1; }
|
||||
.sb-card h3 {
|
||||
margin: 0 0 13px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .7px;
|
||||
text-transform: uppercase;
|
||||
color: var(--sb-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.sb-card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
||||
.sb-card h3 .tag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--sb-money);
|
||||
background: var(--sb-money-soft);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
|
||||
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
|
||||
.sb-row { margin-bottom: 12px; }
|
||||
.sb-row:last-child { margin-bottom: 0; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
|
||||
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
|
||||
|
||||
input.f, select.f, textarea.f {
|
||||
width: 100%;
|
||||
background: var(--sb-field);
|
||||
color: var(--sb-text);
|
||||
border: 1px solid var(--sb-field-border);
|
||||
border-radius: 9px;
|
||||
// !important so Odoo's backend input normalisation can't strip the
|
||||
// field padding inside a client action.
|
||||
padding: 10px 12px !important;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border .15s, box-shadow .15s;
|
||||
}
|
||||
input.f:focus, select.f:focus, textarea.f:focus {
|
||||
border-color: var(--sb-field-focus);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sb-field-focus) 22%, transparent);
|
||||
}
|
||||
textarea.f { resize: vertical; min-height: 56px; }
|
||||
|
||||
.hint { font-size: 11px; color: var(--sb-faint); margin-top: 5px; }
|
||||
.with-icon { position: relative; }
|
||||
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
|
||||
|
||||
// live customer-search results dropdown
|
||||
.sb-cust-search { position: relative; }
|
||||
.sb-cust-results {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 100%;
|
||||
margin-top: 4px;
|
||||
z-index: 30;
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 8px 24px rgba(16, 24, 40, .16);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sb-cust-loading { padding: 10px 12px; font-size: 12.5px; color: var(--sb-faint); }
|
||||
.sb-cust-item { padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--sb-border); }
|
||||
.sb-cust-item:last-child { border-bottom: none; }
|
||||
.sb-cust-item:hover { background: var(--sb-chip); }
|
||||
.sb-cust-name { font-size: 13.5px; font-weight: 600; color: var(--sb-text); }
|
||||
.sb-cust-meta { font-size: 11.5px; color: var(--sb-faint); margin-top: 1px; }
|
||||
.sb-cust-linked { color: var(--sb-ok); font-weight: 600; }
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
background: var(--sb-chip);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
}
|
||||
.seg button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--sb-muted);
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.seg button.on { background: var(--sb-card); color: var(--sb-accent); box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); }
|
||||
.seg.full { display: flex; }
|
||||
.seg.full button { flex: 1; }
|
||||
|
||||
.timepick { display: inline-flex; align-items: stretch; gap: 7px; }
|
||||
.timepick select.f { width: auto; padding-right: 24px; }
|
||||
.ampm { display: inline-flex; background: var(--sb-chip); border: 1px solid var(--sb-border); border-radius: 9px; padding: 3px; }
|
||||
.ampm button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--sb-muted);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ampm button.on { background: var(--sb-accent); color: #fff; }
|
||||
.endtime { font-size: 13px; color: var(--sb-muted); margin-top: 7px; }
|
||||
.endtime b { color: var(--sb-text); }
|
||||
|
||||
.avail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--sb-ok);
|
||||
background: color-mix(in srgb, var(--sb-ok) 14%, transparent);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--sb-border);
|
||||
}
|
||||
.opt:last-child { border-bottom: none; }
|
||||
.opt .lab { font-size: 13.5px; font-weight: 500; }
|
||||
.opt .lab small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11.5px; }
|
||||
|
||||
.sw {
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 20px;
|
||||
background: var(--sb-field-border);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: left .15s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .3);
|
||||
}
|
||||
.sw.on { background: var(--sb-ok); }
|
||||
.sw.on::after { left: 21px; }
|
||||
|
||||
// fee readout inside Service & Pricing
|
||||
.feeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--sb-money-soft);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.feeline .lbl { font-size: 12.5px; font-weight: 600; color: var(--sb-text); }
|
||||
.feeline .lbl small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11px; }
|
||||
.feeline .amt { font-size: 20px; font-weight: 800; color: var(--sb-money); }
|
||||
|
||||
// ESTIMATE strip
|
||||
.estimate {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--sb-money-soft);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-left: 5px solid var(--sb-money);
|
||||
border-radius: 13px;
|
||||
padding: 15px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.estimate .breakdown { display: flex; gap: 18px; flex-wrap: wrap; flex: 1; }
|
||||
.estimate .bk .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-faint); }
|
||||
.estimate .bk .v { font-size: 15px; font-weight: 700; margin-top: 1px; }
|
||||
.estimate .total { text-align: right; }
|
||||
.estimate .total .k { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-money); font-weight: 700; }
|
||||
.estimate .total .v { font-size: 27px; font-weight: 800; color: var(--sb-money); line-height: 1; }
|
||||
.estimate .total .note { font-size: 11px; color: var(--sb-faint); margin-top: 3px; }
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 11px;
|
||||
padding: 16px 24px;
|
||||
background: var(--sb-panel);
|
||||
border-top: 1px solid var(--sb-border);
|
||||
}
|
||||
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
|
||||
|
||||
.sb-btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 18px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sb-btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
||||
.sb-btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #5ba848, #2e7aad);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
|
||||
}
|
||||
.sb-btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
.hide { display: none !important; }
|
||||
|
||||
// Responsive overrides — MUST come AFTER the base layout rules above. These
|
||||
// selectors (.two/.three/.timepick/.sb-grid/.foot/…) have the same specificity
|
||||
// as their base rules, so the cascade only lets the media query win when it is
|
||||
// emitted later in the source. Previously this block sat right after .sb-grid
|
||||
// (BEFORE the base .two/.three/.timepick rules), so the later base rules
|
||||
// overrode it and the inner field-grids never collapsed to one column on a
|
||||
// phone — fields crammed side-by-side. Keep these last.
|
||||
@media (max-width: 780px) {
|
||||
.sb-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.wrap { margin: 12px auto; padding: 0 10px; }
|
||||
.body { padding: 14px 16px 4px; }
|
||||
.topbar { padding: 14px 16px; }
|
||||
.foot { padding: 14px 16px; flex-wrap: wrap; }
|
||||
.two, .three { grid-template-columns: 1fr; }
|
||||
.timepick { flex-wrap: wrap; }
|
||||
}
|
||||
}
|
||||
220
fusion_claims/static/src/xml/service_booking.xml
Normal file
220
fusion_claims/static/src/xml/service_booking.xml
Normal file
@@ -0,0 +1,220 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
|
||||
<div class="o_service_booking" t-ref="root">
|
||||
<div class="wrap">
|
||||
<div class="dialog">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>Book a Service</h1>
|
||||
<div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<span class="step active">Scheduled</span>
|
||||
<span class="step">En Route</span>
|
||||
<span class="step">In Progress</span>
|
||||
<span class="step">Completed</span>
|
||||
<span class="step draft">● Draft repair SO will be created</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="sb-grid">
|
||||
<!-- CUSTOMER -->
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Customer</h3>
|
||||
<div class="sb-row">
|
||||
<div class="seg full">
|
||||
<button t-att-class="{ on: state.custMode === 'existing' }"
|
||||
t-on-click="() => this.setCust('existing')">Existing customer</button>
|
||||
<button t-att-class="{ on: state.custMode === 'new' }"
|
||||
t-on-click="() => this.setCust('new')">New client</button>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'existing'">
|
||||
<div class="sb-row sb-cust-search">
|
||||
<label class="fl">Search by phone, name or SO</label>
|
||||
<input class="f" t-att-value="state.soSearch" t-on-input="onCustSearch"
|
||||
placeholder="e.g. (416) 555-0142 …" autocomplete="off"/>
|
||||
<div class="hint" t-if="!state.partnerId">Inbound call? Type the phone number — we match the contact & their history.</div>
|
||||
<div class="hint sb-cust-linked" t-if="state.partnerId">✓ Linked to existing contact — booking will use it.</div>
|
||||
<div class="sb-cust-results" t-if="state.custSearching or state.custResults.length">
|
||||
<div class="sb-cust-loading" t-if="state.custSearching">Searching…</div>
|
||||
<t t-else="">
|
||||
<div class="sb-cust-item" t-foreach="state.custResults" t-as="c" t-key="c.id"
|
||||
t-on-click="() => this.pickCustomer(c)">
|
||||
<div class="sb-cust-name"><t t-esc="c.name"/></div>
|
||||
<div class="sb-cust-meta"><t t-esc="c.phone"/><t t-if="c.phone and c.city"> · </t><t t-esc="c.city"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'new'">
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
|
||||
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
|
||||
</div>
|
||||
<div class="sb-row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
|
||||
<div class="sb-row"><label class="fl">Address</label>
|
||||
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="sb-row three">
|
||||
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
|
||||
</div>
|
||||
<div class="hint">Contact is created & linked on save — all from this page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE & PRICING -->
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
||||
<div class="sb-row two">
|
||||
<div>
|
||||
<label class="fl">Device being serviced</label>
|
||||
<select class="f" t-on-change="onDevice">
|
||||
<option value="standard">Mobility Scooter</option>
|
||||
<option value="standard">Powerchair</option>
|
||||
<option value="standard">Wheelchair</option>
|
||||
<option value="lift">Stairlift</option>
|
||||
<option value="lift">Patient / Ceiling Lift</option>
|
||||
<option value="standard">Lift Chair</option>
|
||||
<option value="standard">Hospital Bed</option>
|
||||
<option value="standard">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Issue / symptom</label>
|
||||
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-row" t-if="!state.inShop">
|
||||
<label class="fl">Service call type</label>
|
||||
<select class="f"
|
||||
t-on-change="onCallType">
|
||||
<t t-foreach="state.calloutRates" t-as="r" t-key="r.code">
|
||||
<option t-att-value="r.code"
|
||||
t-att-selected="state.category === r.category and state.timing === r.timing">
|
||||
<t t-esc="r.name"/> — $<t t-esc="fmt(r.price)"/><t t-if="r.adds_per_km"> + $<t t-esc="fmt(state.perKm)"/>/km ×2-way</t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<div class="hint">Auto-suggested from the device — change if needed.</div>
|
||||
</div>
|
||||
<div class="feeline" t-if="!state.inShop and callout">
|
||||
<div class="lbl">Call-out fee<small><t t-esc="callout.name"/><t t-if="callout.adds_per_km"> · + travel</t><t t-else=""> · includes 30 min labour</t></small></div>
|
||||
<div class="amt">$<t t-esc="fmt(callout.price)"/></div>
|
||||
</div>
|
||||
<div class="hint" t-if="state.inShop">In-shop job — no call-out fee; labour billed at $<t t-esc="fmt(state.labour.inshop)"/>/hr.</div>
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULE -->
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Schedule</h3>
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
|
||||
<div><label class="fl">Duration</label>
|
||||
<select class="f" t-model.number="state.durationHr">
|
||||
<option value="0.5">30 min</option>
|
||||
<option value="1">1 hour</option>
|
||||
<option value="1.5">1.5 hours</option>
|
||||
<option value="2">2 hours</option>
|
||||
<option value="3">3 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-row">
|
||||
<label class="fl">Start time</label>
|
||||
<div class="timepick">
|
||||
<select class="f" t-model.number="state.hour">
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
<select class="f" t-model.number="state.minute">
|
||||
<option value="0">:00</option>
|
||||
<option value="15">:15</option>
|
||||
<option value="30">:30</option>
|
||||
<option value="45">:45</option>
|
||||
</select>
|
||||
<div class="ampm">
|
||||
<button t-att-class="{ on: state.ampm === 'AM' }" t-on-click="() => this.setAmpm('AM')">AM</button>
|
||||
<button t-att-class="{ on: state.ampm === 'PM' }" t-on-click="() => this.setAmpm('PM')">PM</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
|
||||
</div>
|
||||
<div class="sb-row">
|
||||
<label class="fl">Technician</label>
|
||||
<select class="f" t-model.number="state.technicianId">
|
||||
<option value="">— Choose —</option>
|
||||
<t t-foreach="state.technicians" t-as="t" t-key="t.id">
|
||||
<option t-att-value="t.id"><t t-esc="t.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOCATION -->
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Location</h3>
|
||||
<div class="opt" style="border:none; padding-top:0;">
|
||||
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
|
||||
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
|
||||
</div>
|
||||
<div t-if="!state.inShop">
|
||||
<div class="sb-row"><label class="fl">Job address</label>
|
||||
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JOB DETAILS -->
|
||||
<div class="sb-card span2">
|
||||
<h3><span class="dot"></span>Job details</h3>
|
||||
<div class="two">
|
||||
<div class="sb-row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||
<div class="sb-row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||
</div>
|
||||
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
|
||||
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
|
||||
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw" t-att-class="{ on: state.emailConfirm }" t-on-click="() => state.emailConfirm = !state.emailConfirm"></div></div>
|
||||
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw" t-att-class="{ on: state.googleReview }" t-on-click="() => state.googleReview = !state.googleReview"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ESTIMATE -->
|
||||
<div class="estimate">
|
||||
<div class="breakdown">
|
||||
<div class="bk"><div class="k">Call-out</div><div class="v"><t t-if="state.inShop">—</t><t t-else="">$<t t-esc="fmt(estimate.callout)"/></t></div></div>
|
||||
<div class="bk"><div class="k">Est. labour</div><div class="v">$<t t-esc="fmt(estimate.labour)"/> · <t t-esc="estimate.billHr"/>h @ $<t t-esc="fmt(labourRate)"/></div></div>
|
||||
<div class="bk" t-if="estimate.addsKm"><div class="k">Travel ($<t t-esc="fmt(state.perKm)"/>/km ×2)</div><div class="v">$<t t-esc="fmt(estimate.km)"/></div></div>
|
||||
</div>
|
||||
<div class="total"><div class="k">Estimated total</div><div class="v">$<t t-esc="fmt(estimate.total)"/></div>
|
||||
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
|
||||
<button class="sb-btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
|
||||
<button class="sb-btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -3,3 +3,5 @@
|
||||
from . import test_signed_pages_gate
|
||||
from . import test_application_received_wizard
|
||||
from . import test_dashboard
|
||||
from . import test_service_rate
|
||||
from . import test_service_booking
|
||||
|
||||
75
fusion_claims/tests/test_service_booking.py
Normal file
75
fusion_claims/tests/test_service_booking.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestServiceBooking(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Task = cls.env['fusion.technician.task']
|
||||
# technician_id is required on a task (domain x_fc_is_field_staff=True).
|
||||
cls.tech = cls.env['res.users'].create({
|
||||
'name': 'Service Booking Tech',
|
||||
'login': 'svcbook_tech',
|
||||
'x_fc_is_field_staff': True,
|
||||
})
|
||||
|
||||
def test_task_without_order_is_allowed(self):
|
||||
# No SO/PO must NOT raise after the relax. description is required and a
|
||||
# non-in-store task needs an address, so set both here to isolate the test
|
||||
# to the order-link relaxation (not those unrelated base constraints).
|
||||
t = self.Task.create({
|
||||
'task_type': 'repair',
|
||||
'technician_id': self.tech.id,
|
||||
'scheduled_date': date.today() + timedelta(days=7),
|
||||
'description': 'Test repair',
|
||||
'is_in_store': True,
|
||||
})
|
||||
self.assertTrue(t.id)
|
||||
|
||||
def test_sale_order_has_service_repair_flag(self):
|
||||
so = self.env['sale.order'].new({})
|
||||
self.assertIn('x_fc_is_service_repair', so._fields)
|
||||
|
||||
def test_resolve_service_lines_standard_rush(self):
|
||||
Task = self.Task
|
||||
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
|
||||
# call-out $120 + per-km line qty 20 @ $0.70
|
||||
callout = [l for l in lines if l['price_unit'] == 120.0]
|
||||
per_km = [l for l in lines if l['name_is_km']]
|
||||
self.assertTrue(callout)
|
||||
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
|
||||
self.assertEqual(per_km[0]['price_unit'], 0.70)
|
||||
|
||||
def test_resolve_service_lines_in_shop_empty_callout(self):
|
||||
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
|
||||
self.assertEqual(lines, [])
|
||||
|
||||
def test_build_service_so(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
|
||||
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
|
||||
self.assertEqual(so.state, 'draft')
|
||||
self.assertTrue(so.x_fc_is_service_repair)
|
||||
self.assertEqual(so.partner_id, partner)
|
||||
self.assertEqual(so.order_line[0].price_unit, 95.0)
|
||||
|
||||
def test_action_book_creates_contact_task_and_so(self):
|
||||
payload = {
|
||||
'cust_mode': 'new',
|
||||
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
|
||||
'street': '88 Bloor St E', 'city': 'Toronto'},
|
||||
'category': 'standard', 'timing': 'normal', 'in_shop': False,
|
||||
'device': 'scooter', 'issue': "won't power on",
|
||||
'date': (date.today() + timedelta(days=7)).strftime('%Y-%m-%d'), 'time_start': 9.0, 'duration_hr': 1.0,
|
||||
'technician_id': self.tech.id, 'description': 'check battery',
|
||||
}
|
||||
res = self.Task.action_book_from_wizard(payload)
|
||||
self.assertTrue(res['task_id'] and res['order_id'])
|
||||
task = self.Task.browse(res['task_id'])
|
||||
self.assertEqual(task.sale_order_id.id, res['order_id'])
|
||||
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
|
||||
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
|
||||
self.assertTrue(partner)
|
||||
60
fusion_claims/tests/test_service_rate.py
Normal file
60
fusion_claims/tests/test_service_rate.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestServiceRate(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Rate = cls.env['fusion.service.rate']
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'Test Service Product', 'type': 'service',
|
||||
})
|
||||
|
||||
def _make(self, **kw):
|
||||
vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard',
|
||||
timing='normal', product_id=self.product.id, price=95.0, unit='fixed')
|
||||
vals.update(kw)
|
||||
return self.Rate.create(vals)
|
||||
|
||||
def test_get_callout_matches_category_and_timing(self):
|
||||
# Assert against the real seed (codes are unique, so creating colliding
|
||||
# standard/normal rows would violate the UNIQUE(code) constraint).
|
||||
r = self.Rate.get_callout('standard', 'normal')
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(r.code, 'callout_standard_normal')
|
||||
self.assertEqual(r.rate_kind, 'callout')
|
||||
|
||||
def test_get_callout_in_shop_returns_empty(self):
|
||||
self._make(code='callout_standard_normal_b')
|
||||
self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True))
|
||||
|
||||
def test_get_rate_by_code(self):
|
||||
# 'per_km' is a seeded code; the resolver returns that row.
|
||||
r = self.Rate.get_rate('per_km')
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(r.unit, 'per_km')
|
||||
|
||||
def test_code_must_be_unique(self):
|
||||
self._make(code='dup')
|
||||
with self.assertRaises(Exception):
|
||||
self._make(code='dup')
|
||||
self.env.flush_all()
|
||||
|
||||
def test_seeded_callouts_exist(self):
|
||||
# standard normal $95, lift after-hours $205 are the canonical seeds
|
||||
std = self.env.ref('fusion_claims.rate_callout_standard_normal')
|
||||
self.assertEqual(std.price, 95.0)
|
||||
self.assertEqual(std.rate_kind, 'callout')
|
||||
self.assertTrue(std.product_id)
|
||||
lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours')
|
||||
self.assertEqual(lift_ah.price, 205.0)
|
||||
self.assertTrue(lift_ah.adds_per_km)
|
||||
|
||||
def test_seeded_per_km(self):
|
||||
km = self.env['fusion.service.rate'].get_rate('per_km')
|
||||
self.assertTrue(km)
|
||||
self.assertEqual(km.unit, 'per_km')
|
||||
self.assertEqual(km.price, 0.70)
|
||||
18
fusion_claims/views/service_booking_action.xml
Normal file
18
fusion_claims/views/service_booking_action.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2024-2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="action_service_booking_wizard" model="ir.actions.client">
|
||||
<field name="name">Book a Service</field>
|
||||
<field name="tag">fusion_claims.service_booking</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_service_booking"
|
||||
name="Book a Service"
|
||||
parent="fusion_tasks.menu_field_service_root"
|
||||
action="action_service_booking_wizard"
|
||||
sequence="1"/>
|
||||
</odoo>
|
||||
101
fusion_claims/views/service_rate_views.xml
Normal file
101
fusion_claims/views/service_rate_views.xml
Normal file
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2024-2025 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: List View (inline-edit enabled) -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_fusion_service_rate_list" model="ir.ui.view">
|
||||
<field name="name">fusion.service.rate.list</field>
|
||||
<field name="model">fusion.service.rate</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Service Rates" editable="top"
|
||||
default_order="sequence, rate_kind, category, timing">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="rate_kind" string="Kind"/>
|
||||
<field name="category"/>
|
||||
<field name="timing"/>
|
||||
<field name="unit"/>
|
||||
<field name="price" string="Rate"/>
|
||||
<field name="currency_id" column_invisible="True"/>
|
||||
<field name="adds_per_km" string="+ km"/>
|
||||
<field name="included_labour_min" string="Incl. Labour (min)"/>
|
||||
<field name="in_shop" string="In-Shop"/>
|
||||
<field name="product_id" string="Invoice Product"/>
|
||||
<field name="active" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Form View -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_fusion_service_rate_form" model="ir.ui.view">
|
||||
<field name="name">fusion.service.rate.form</field>
|
||||
<field name="model">fusion.service.rate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Service Rate">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Rate name…"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Identification">
|
||||
<field name="code"/>
|
||||
<field name="rate_kind" string="Kind"/>
|
||||
<field name="category"/>
|
||||
<field name="timing"/>
|
||||
<field name="in_shop"/>
|
||||
<field name="active"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group string="Pricing">
|
||||
<field name="price" string="Rate"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="unit"/>
|
||||
<field name="adds_per_km"/>
|
||||
<field name="included_labour_min"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Invoice Product">
|
||||
<field name="product_id" string="Product" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Action -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="action_fusion_service_rate" model="ir.actions.act_window">
|
||||
<field name="name">Service Rates</field>
|
||||
<field name="res_model">fusion.service.rate</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'active_test': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No service rates found.
|
||||
</p>
|
||||
<p>
|
||||
Add rates used for booking service calls, labour, travel, and delivery.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Menu item under Technician Configuration -->
|
||||
<!-- ===================================================================== -->
|
||||
<menuitem id="menu_fusion_service_rate"
|
||||
name="Service Rates"
|
||||
parent="fusion_tasks.menu_technician_config"
|
||||
action="action_fusion_service_rate"
|
||||
sequence="50"
|
||||
groups="base.group_system"/>
|
||||
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.4.1.0',
|
||||
'version': '19.0.4.2.0',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
|
||||
@@ -287,6 +287,11 @@ class FusionClockAPI(http.Controller):
|
||||
|
||||
attendance.sudo().write(write_vals)
|
||||
|
||||
# A successful clock-in resolves any pending missed-clock-out flag,
|
||||
# so the employee is never nagged once they are back on the clock.
|
||||
if employee.x_fclk_pending_reason:
|
||||
employee.sudo().write({'x_fclk_pending_reason': False})
|
||||
|
||||
# Log clock-in
|
||||
self._log_activity(
|
||||
employee, 'clock_in',
|
||||
@@ -542,7 +547,10 @@ class FusionClockAPI(http.Controller):
|
||||
'is_checked_in': is_checked_in,
|
||||
'employee_name': employee.name,
|
||||
'enable_clock': employee.x_fclk_enable_clock,
|
||||
'pending_reason': employee.x_fclk_pending_reason,
|
||||
# Only nag when there is genuinely something to explain: a flag set,
|
||||
# the employee NOT currently on the clock, and not attendance-exempt.
|
||||
'pending_reason': (employee.x_fclk_pending_reason and not is_checked_in
|
||||
and not employee._fclk_is_attendance_exempt()),
|
||||
'ontime_streak': employee.x_fclk_ontime_streak,
|
||||
}
|
||||
local_today = get_local_today(request.env, employee)
|
||||
@@ -728,7 +736,8 @@ class FusionClockAPI(http.Controller):
|
||||
'is_checked_in': is_checked_in,
|
||||
'check_in': check_in,
|
||||
'location_name': location_name,
|
||||
'pending_reason': employee.x_fclk_pending_reason,
|
||||
'pending_reason': (employee.x_fclk_pending_reason and not is_checked_in
|
||||
and not employee._fclk_is_attendance_exempt()),
|
||||
'today_hours': today_hours,
|
||||
'week_hours': week_hours,
|
||||
'overtime_week': round(employee.x_fclk_overtime_this_week or 0, 2),
|
||||
|
||||
@@ -137,6 +137,9 @@ class FusionClockKiosk(http.Controller):
|
||||
'x_fclk_clock_source': 'kiosk',
|
||||
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
# Back on the clock -> clear any stale missed-clock-out flag.
|
||||
if employee.x_fclk_pending_reason:
|
||||
employee.sudo().write({'x_fclk_pending_reason': False})
|
||||
api._log_activity(employee, 'clock_in', f"Kiosk clock-in at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||
|
||||
@@ -345,6 +345,9 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
'x_fclk_clock_source': 'nfc_kiosk',
|
||||
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
# Back on the clock -> clear any stale missed-clock-out flag.
|
||||
if employee.x_fclk_pending_reason:
|
||||
employee.sudo().write({'x_fclk_pending_reason': False})
|
||||
api._log_activity(
|
||||
employee, 'clock_in',
|
||||
f"NFC kiosk clock-in at {location.name}",
|
||||
|
||||
25
fusion_clock/migrations/19.0.4.2.0/post-migrate.py
Normal file
25
fusion_clock/migrations/19.0.4.2.0/post-migrate.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""One-time reset of stale missed-clock-out flags on upgrade to 19.0.4.1.0.
|
||||
|
||||
Background: x_fclk_pending_reason was set by the absence + auto-clock-out crons
|
||||
but only cleared by the systray reason dialog -- never by the kiosk / NFC clock
|
||||
paths that staff actually use. During the kiosk rollout the absence cron flagged
|
||||
essentially the whole company (hundreds of "absent" logs), and those flags then
|
||||
nagged everyone forever, even while currently clocked in.
|
||||
|
||||
This release clears the flag on every clock-in (all paths), stops absences from
|
||||
setting it at all, and exempts owners. The flags already on record are stale
|
||||
artifacts of the rollout, so wipe them once here; correct ones re-appear only
|
||||
for a genuine forgotten clock-out from now on.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return
|
||||
cr.execute(
|
||||
"UPDATE hr_employee SET x_fclk_pending_reason = false "
|
||||
"WHERE x_fclk_pending_reason = true"
|
||||
)
|
||||
@@ -345,6 +345,9 @@ class HrAttendance(models.Model):
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
# Owners / attendance-exempt employees are never auto-clocked-out or nagged.
|
||||
if employee._fclk_is_attendance_exempt():
|
||||
continue
|
||||
clock_out_time = effective_deadline
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
@@ -456,6 +459,9 @@ class HrAttendance(models.Model):
|
||||
for emp in employees:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
# Owners / attendance-exempt employees are never flagged absent.
|
||||
if emp._fclk_is_attendance_exempt():
|
||||
continue
|
||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||
|
||||
# Only days the employee was actually scheduled to work
|
||||
@@ -498,7 +504,11 @@ class HrAttendance(models.Model):
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
# NOTE: an absence does NOT set x_fclk_pending_reason. That flag
|
||||
# drives the "explain your missed clock-OUT (departure time)"
|
||||
# dialog, which is meaningless for a day with no attendance and
|
||||
# caused a persistent false nag. The absence is logged + the
|
||||
# office is notified on excess; that is the absence remedy.
|
||||
|
||||
month_start = yesterday.replace(day=1)
|
||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
@@ -546,6 +556,9 @@ class HrAttendance(models.Model):
|
||||
for emp in employees:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
# Owners / attendance-exempt employees are never reminded.
|
||||
if emp._fclk_is_attendance_exempt():
|
||||
continue
|
||||
today = get_local_today(self.env, emp)
|
||||
if not emp._get_fclk_day_plan(today).get('scheduled'):
|
||||
continue
|
||||
@@ -610,6 +623,9 @@ class HrAttendance(models.Model):
|
||||
company_name = company.name or ''
|
||||
|
||||
for emp in employees:
|
||||
# Owners / attendance-exempt employees get no weekly summary.
|
||||
if emp._fclk_is_attendance_exempt():
|
||||
continue
|
||||
if not emp.work_email:
|
||||
continue
|
||||
|
||||
|
||||
@@ -40,6 +40,18 @@ class HrEmployee(models.Model):
|
||||
help="If set, employee must explain a missed clock-out before clocking in again.",
|
||||
)
|
||||
|
||||
# Attendance exemption (owners / anyone who works but is not "on the clock").
|
||||
# Exempt employees are skipped by absence detection, auto-clock-out and
|
||||
# reminders, and never see the missed-clock-out reason dialog.
|
||||
x_fclk_exempt_from_attendance = fields.Boolean(
|
||||
string='Exempt from Attendance Tracking',
|
||||
default=False,
|
||||
help="If set, this employee is never flagged absent, auto-clocked-out, "
|
||||
"reminded, or asked to explain a missed clock-out. Use for owners "
|
||||
"and others who work but are not on the clock. The Fusion Clock "
|
||||
"'Owner' role grants this automatically.",
|
||||
)
|
||||
|
||||
# Kiosk PIN
|
||||
x_fclk_kiosk_pin = fields.Char(
|
||||
string='Kiosk PIN',
|
||||
@@ -122,6 +134,19 @@ class HrEmployee(models.Model):
|
||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
||||
)
|
||||
|
||||
def _fclk_is_attendance_exempt(self):
|
||||
"""True when this employee is exempt from attendance automation.
|
||||
|
||||
Exempt = the per-employee checkbox is set, OR the linked user holds the
|
||||
Fusion Clock 'Owner' role. Exempt employees are never flagged absent,
|
||||
auto-clocked-out, reminded, or shown the missed-clock-out reason dialog.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_exempt_from_attendance:
|
||||
return True
|
||||
user = self.user_id
|
||||
return bool(user) and user.has_group('fusion_clock.group_fusion_clock_owner')
|
||||
|
||||
def _get_fclk_schedule_for_date(self, date):
|
||||
"""Return this employee's dated Fusion Clock schedule for a local date."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -49,6 +49,18 @@
|
||||
<field name="comment">Can manage locations, view all attendance, generate reports</field>
|
||||
</record>
|
||||
|
||||
<!-- Owner: top of the role ladder. Carries ALL Manager permissions but is
|
||||
exempt from attendance automation (no absence flags, no auto-clock-out
|
||||
nag, no reminders, no missed-clock-out dialog). For owners/principals
|
||||
who work but are not "on the clock". Implies Manager, so it renders as
|
||||
the highest role in the single Fusion Clock access dropdown. -->
|
||||
<record id="group_fusion_clock_owner" model="res.groups">
|
||||
<field name="name">Owner</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
<field name="comment">Full Clock management; exempt from attendance tracking, reminders and missed-clock alerts.</field>
|
||||
</record>
|
||||
|
||||
<!-- Dedicated kiosk-operator permission: can run the shared clock kiosk
|
||||
(NFC tap / PIN) WITHOUT full Clock Manager access. Gates the
|
||||
"Fusion Clock Kiosk" app menu and is accepted by the kiosk controllers.
|
||||
|
||||
@@ -71,7 +71,10 @@ export class FusionClockFAB extends Component {
|
||||
this.state.todayHours = (result.today_hours || 0).toFixed(1);
|
||||
this.state.weekHours = (result.week_hours || 0).toFixed(1);
|
||||
|
||||
if (result.pending_reason) {
|
||||
// Never raise the missed-clock-out dialog while the employee is
|
||||
// currently on the clock (the server already guards this, but keep
|
||||
// the UI honest too).
|
||||
if (result.pending_reason && !result.is_checked_in) {
|
||||
this.state.showReasonDialog = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ from . import test_pay_period
|
||||
from . import test_settings
|
||||
from . import test_clock_kiosk
|
||||
from . import test_break_rules
|
||||
from . import test_pending_reason_exempt
|
||||
|
||||
241
fusion_clock/tests/test_pending_reason_exempt.py
Normal file
241
fusion_clock/tests/test_pending_reason_exempt.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Regression tests for the missed-clock-out ("pending reason") nag and the
|
||||
new owner/attendance-exemption.
|
||||
|
||||
Root cause these tests pin down:
|
||||
* The `x_fclk_pending_reason` flag was set by the absence + auto-clock-out
|
||||
crons but ONLY cleared by the systray reason dialog. The kiosk / NFC clock
|
||||
paths (how Entech actually clocks in) never cleared it, so a stale flag
|
||||
nagged employees forever -- even while currently clocked in.
|
||||
* Owners work but are not "on the clock"; they must be exempt from absence
|
||||
flagging, auto-clock-out nags and the reason dialog.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase, TransactionCase
|
||||
|
||||
try:
|
||||
from freezegun import freeze_time
|
||||
except ImportError: # freezegun may be absent on the runtime image
|
||||
freeze_time = None
|
||||
|
||||
MON = date(2026, 6, 1) # Monday
|
||||
TUE = date(2026, 6, 2) # Tuesday
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestAttendanceExemptHelper(TransactionCase):
|
||||
"""`hr.employee._fclk_is_attendance_exempt()` truth table."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Employee = cls.env['hr.employee']
|
||||
cls.owner_group = cls.env.ref('fusion_clock.group_fusion_clock_owner')
|
||||
|
||||
def test_plain_employee_not_exempt(self):
|
||||
emp = self.Employee.create({'name': 'Plain', 'x_fclk_enable_clock': True})
|
||||
self.assertFalse(emp._fclk_is_attendance_exempt())
|
||||
|
||||
def test_checkbox_makes_exempt(self):
|
||||
emp = self.Employee.create({
|
||||
'name': 'Flagged', 'x_fclk_enable_clock': True,
|
||||
'x_fclk_exempt_from_attendance': True,
|
||||
})
|
||||
self.assertTrue(emp._fclk_is_attendance_exempt())
|
||||
|
||||
def test_owner_group_makes_exempt(self):
|
||||
user = self.env['res.users'].create({
|
||||
'name': 'Olivia Owner', 'login': 'olivia-owner-test',
|
||||
'group_ids': [(4, self.owner_group.id)],
|
||||
})
|
||||
emp = self.Employee.create({
|
||||
'name': 'Olivia Owner', 'x_fclk_enable_clock': True, 'user_id': user.id,
|
||||
})
|
||||
self.assertTrue(emp._fclk_is_attendance_exempt())
|
||||
|
||||
def test_owner_group_implies_manager(self):
|
||||
"""The Owner role must carry full Manager permissions."""
|
||||
user = self.env['res.users'].create({
|
||||
'name': 'Manager-by-owner', 'login': 'owner-implies-mgr',
|
||||
'group_ids': [(4, self.owner_group.id)],
|
||||
})
|
||||
self.assertTrue(user.has_group('fusion_clock.group_fusion_clock_manager'))
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestCronsRespectExemptAndPending(TransactionCase):
|
||||
"""Absence + auto-clock-out crons: no more pending nag, owners skipped."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Employee = cls.env['hr.employee']
|
||||
cls.Schedule = cls.env['fusion.clock.schedule']
|
||||
cls.Attendance = cls.env['hr.attendance']
|
||||
cls.Log = cls.env['fusion.clock.activity.log']
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_auto_clockout', 'True')
|
||||
cls.ICP.set_param('fusion_clock.max_shift_hours', '16')
|
||||
|
||||
def _post(self, emp, day):
|
||||
return self.Schedule.create({
|
||||
'employee_id': emp.id, 'schedule_date': day, 'state': 'posted',
|
||||
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
|
||||
})
|
||||
|
||||
def test_absence_does_not_set_pending_reason(self):
|
||||
if freeze_time is None:
|
||||
self.skipTest("freezegun not available")
|
||||
emp = self.Employee.create({'name': 'NoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC'})
|
||||
self._post(emp, MON)
|
||||
with freeze_time("2026-06-02 09:00:00"): # yesterday = scheduled Monday
|
||||
self.Attendance._cron_fusion_check_absences()
|
||||
# Absence is still logged ...
|
||||
self.assertEqual(self.Log.search_count([
|
||||
('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 1)
|
||||
# ... but it must NOT raise the missed-clock-out reason nag.
|
||||
self.assertFalse(emp.x_fclk_pending_reason)
|
||||
|
||||
def test_absence_skips_exempt_employee(self):
|
||||
if freeze_time is None:
|
||||
self.skipTest("freezegun not available")
|
||||
emp = self.Employee.create({
|
||||
'name': 'OwnerNoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC',
|
||||
'x_fclk_exempt_from_attendance': True,
|
||||
})
|
||||
self._post(emp, MON)
|
||||
with freeze_time("2026-06-02 09:00:00"):
|
||||
self.Attendance._cron_fusion_check_absences()
|
||||
self.assertEqual(self.Log.search_count([
|
||||
('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 0)
|
||||
self.assertFalse(emp.x_fclk_pending_reason)
|
||||
|
||||
def test_auto_clockout_skips_exempt_employee(self):
|
||||
emp = self.Employee.create({
|
||||
'name': 'OwnerStale', 'x_fclk_enable_clock': True, 'tz': 'UTC',
|
||||
'x_fclk_exempt_from_attendance': True,
|
||||
})
|
||||
now = fields.Datetime.now()
|
||||
stale = self.Attendance.create({
|
||||
'employee_id': emp.id, 'check_in': now - timedelta(hours=20),
|
||||
})
|
||||
self.Attendance._cron_fusion_auto_clock_out()
|
||||
self.assertFalse(stale.check_out, "Exempt employee must not be auto-clocked-out.")
|
||||
self.assertFalse(emp.x_fclk_pending_reason)
|
||||
|
||||
def test_auto_clockout_still_flags_normal_employee(self):
|
||||
emp = self.Employee.create({'name': 'Forgetful', 'x_fclk_enable_clock': True, 'tz': 'UTC'})
|
||||
now = fields.Datetime.now()
|
||||
stale = self.Attendance.create({
|
||||
'employee_id': emp.id, 'check_in': now - timedelta(hours=20),
|
||||
})
|
||||
self.Attendance._cron_fusion_auto_clock_out()
|
||||
self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")
|
||||
self.assertTrue(emp.x_fclk_pending_reason, "Forgotten clock-out still asks for a reason.")
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestKioskClearsPendingReason(HttpCase):
|
||||
"""Clocking in via either kiosk clears a stale pending-reason flag."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Clear Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.env['res.users'].create({
|
||||
'name': 'Clear Op', 'login': 'clear-op', 'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.pin_emp = cls.env['hr.employee'].create({
|
||||
'name': 'Pat Pending', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
|
||||
'x_fclk_pending_reason': True,
|
||||
})
|
||||
cls.nfc_emp = cls.env['hr.employee'].create({
|
||||
'name': 'Nina Pending', 'x_fclk_enable_clock': True,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:CC:01', 'x_fclk_pending_reason': True,
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_mod
|
||||
nfc_mod._recent_taps.clear()
|
||||
|
||||
def _post(self, route, params):
|
||||
self.authenticate('clear-op', 'kioskpass123')
|
||||
resp = self.url_open(route, data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call', 'params': params,
|
||||
}), headers={'Content-Type': 'application/json'})
|
||||
return resp.json().get('result', {})
|
||||
|
||||
def test_pin_kiosk_clock_in_clears_pending(self):
|
||||
res = self._post('/fusion_clock/kiosk/clock', {'employee_id': self.pin_emp.id})
|
||||
self.assertEqual(res.get('action'), 'clock_in')
|
||||
self.pin_emp.invalidate_recordset()
|
||||
self.assertFalse(self.pin_emp.x_fclk_pending_reason)
|
||||
|
||||
def test_nfc_tap_clock_in_clears_pending(self):
|
||||
res = self._post('/fusion_clock/kiosk/nfc/tap', {'card_uid': '04:A2:B5:62:CC:01'})
|
||||
self.assertEqual(res.get('action'), 'clock_in')
|
||||
self.nfc_emp.invalidate_recordset()
|
||||
self.assertFalse(self.nfc_emp.x_fclk_pending_reason)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestGetStatusPendingReason(HttpCase):
|
||||
"""get_status must never raise the dialog for a clocked-in or exempt user."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.user = cls.env['res.users'].create({
|
||||
'name': 'Status User', 'login': 'status-user', 'password': 'statuspass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_user').id)],
|
||||
})
|
||||
cls.emp = cls.env['hr.employee'].create({
|
||||
'name': 'Status User', 'x_fclk_enable_clock': True, 'tz': 'UTC',
|
||||
'user_id': cls.user.id, 'x_fclk_pending_reason': True,
|
||||
})
|
||||
|
||||
def _status(self):
|
||||
self.authenticate('status-user', 'statuspass123')
|
||||
resp = self.url_open('/fusion_clock/get_status', data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call', 'params': {},
|
||||
}), headers={'Content-Type': 'application/json'})
|
||||
return resp.json().get('result', {})
|
||||
|
||||
def test_pending_hidden_while_checked_in(self):
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.emp.id, 'check_in': fields.Datetime.now() - timedelta(hours=1),
|
||||
})
|
||||
self.emp.invalidate_recordset()
|
||||
res = self._status()
|
||||
self.assertTrue(res.get('is_checked_in'))
|
||||
self.assertFalse(res.get('pending_reason'),
|
||||
"A currently clocked-in employee must never be nagged.")
|
||||
|
||||
def test_pending_hidden_for_exempt(self):
|
||||
self.emp.write({'x_fclk_exempt_from_attendance': True})
|
||||
res = self._status()
|
||||
self.assertFalse(res.get('is_checked_in'))
|
||||
self.assertFalse(res.get('pending_reason'),
|
||||
"An exempt (owner) employee must never be nagged.")
|
||||
|
||||
def test_pending_shown_for_normal_not_checked_in(self):
|
||||
"""Sanity: the dialog still works for a genuine forgotten clock-out."""
|
||||
res = self._status()
|
||||
self.assertFalse(res.get('is_checked_in'))
|
||||
self.assertTrue(res.get('pending_reason'))
|
||||
@@ -15,6 +15,7 @@
|
||||
<group>
|
||||
<group string="Configuration">
|
||||
<field name="x_fclk_enable_clock"/>
|
||||
<field name="x_fclk_exempt_from_attendance"/>
|
||||
<field name="x_fclk_shift_id"/>
|
||||
<field name="x_fclk_default_location_id"/>
|
||||
<field name="x_fclk_break_minutes"/>
|
||||
|
||||
@@ -346,15 +346,30 @@ class ResUsers(models.Model):
|
||||
string='Login Audit Count',
|
||||
compute='_compute_x_fc_login_audit_count',
|
||||
)
|
||||
# NON-STORED on purpose — do NOT re-add store=True.
|
||||
#
|
||||
# These were store=True computed-from-the-audit-One2many. That meant every
|
||||
# successful-login audit row (written through an INDEPENDENT
|
||||
# registry.cursor(), see _fc_record_login_event) forced a recompute that
|
||||
# flushed a write-back onto THIS res_users row. During portal-invitation
|
||||
# acceptance the request has already locked that row (auth_signup just set
|
||||
# the password in the same transaction), so the audit cursor's write-back
|
||||
# blocked on the request's own row lock while the request's Python blocked
|
||||
# waiting for the audit cursor — a self-deadlock Postgres cannot detect
|
||||
# (the holder shows 'idle in transaction', not lock-waiting). Workers
|
||||
# wedged for up to limit_time_real (20 min) and odoo-westin went
|
||||
# unresponsive every time an invite was accepted (issue 2026-06-03).
|
||||
#
|
||||
# Keeping them non-stored means creating an audit row never touches
|
||||
# res_users. They compute on read (display-only on the user form). The
|
||||
# regression guard is tests.test_last_login_fields_not_stored.
|
||||
x_fc_last_successful_login = fields.Datetime(
|
||||
string='Last Successful Login',
|
||||
compute='_compute_x_fc_last_successful_login',
|
||||
store=True,
|
||||
)
|
||||
x_fc_last_login_ip = fields.Char(
|
||||
string='Last Login IP', size=45,
|
||||
compute='_compute_x_fc_last_successful_login',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('x_fc_login_audit_ids')
|
||||
|
||||
@@ -303,6 +303,54 @@ class TestFusionLoginAuditModel(TransactionCase):
|
||||
self.assertGreaterEqual(user.x_fc_login_audit_count, 1)
|
||||
self.assertEqual(user.x_fc_last_login_ip, '198.51.100.42')
|
||||
|
||||
def test_last_login_fields_not_stored(self):
|
||||
"""Regression guard for the 2026-06-03 invitation-acceptance hang.
|
||||
|
||||
x_fc_last_successful_login / x_fc_last_login_ip MUST stay non-stored.
|
||||
When they were store=True (computed from the audit One2many), creating
|
||||
the success audit row through the independent registry cursor forced a
|
||||
write-back onto the very res_users row the request had already locked
|
||||
(auth_signup had just set the password) -> a self-deadlock Postgres
|
||||
cannot see (the holder shows 'idle in transaction'). Workers wedged for
|
||||
up to limit_time_real and odoo-westin became unresponsive whenever an
|
||||
invitation was accepted. Non-stored means audit-row creation never
|
||||
touches res_users, so the deadlock cannot form.
|
||||
"""
|
||||
fields_ = self.env['res.users']._fields
|
||||
self.assertFalse(
|
||||
fields_['x_fc_last_successful_login'].store,
|
||||
"x_fc_last_successful_login must be non-stored (see docstring)")
|
||||
self.assertFalse(
|
||||
fields_['x_fc_last_login_ip'].store,
|
||||
"x_fc_last_login_ip must be non-stored (see docstring)")
|
||||
|
||||
def test_audit_row_create_does_not_write_res_users(self):
|
||||
"""Creating a login-audit row must not write the linked res_users row.
|
||||
|
||||
This is the behavioural half of the deadlock guard: with the fields
|
||||
non-stored, inserting an audit row for a user leaves that user's
|
||||
write_date untouched (no recompute -> no res_users UPDATE -> nothing
|
||||
to contend with the request's own row lock).
|
||||
"""
|
||||
user = self.env['res.users'].sudo().create({
|
||||
'name': 'NoWriteback Tester',
|
||||
'login': 'nowriteback-tester@example.com',
|
||||
'password': 'nowriteback-tester-pw-1',
|
||||
})
|
||||
user.flush_recordset()
|
||||
before = user.write_date
|
||||
self.env['fusion.login.audit'].sudo().create({
|
||||
'user_id': user.id,
|
||||
'attempted_login': user.login,
|
||||
'result': 'success',
|
||||
'database': self.env.cr.dbname,
|
||||
'ip_address': '198.51.100.7',
|
||||
})
|
||||
user.invalidate_recordset()
|
||||
self.assertEqual(
|
||||
user.write_date, before,
|
||||
"Audit-row create must not write back to res_users")
|
||||
|
||||
def test_action_view_login_audit_returns_window_action(self):
|
||||
"""The smart-button action returns an act_window scoped to this user."""
|
||||
user = self.env['res.users'].sudo().create({
|
||||
|
||||
BIN
fusion_plating/.DS_Store
vendored
BIN
fusion_plating/.DS_Store
vendored
Binary file not shown.
@@ -44,7 +44,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
|
||||
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
|
||||
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). **EXCEPTION — do NOT add `.article` to the dpi=96 mm-based job stickers** (`fusion_plating_jobs/report/report_fp_job_sticker.xml`): those set a custom `@page` + `dpi=96` so mm maps 1:1 (rule 14), and wrapping their body in `<div class="article">` re-routes through Odoo's standard report CSS which **blows up the mm/dpi layout** — the logo + every element renders huge (tested + reverted 2026-06-04). For those labels do the OPPOSITE: leave the raw `html_container` and **strip the offending non-ASCII glyph to ASCII in the Python display helper** instead (`fp_job_sticker.py::_clean()` maps `º`/`°`/`˚`→'' , em-dash→`-`, smart-quotes→ASCII). Trade-off: you lose the literal glyph (a bake temp `375°F` prints `375F`) but the label stays clean + correctly sized. So: `.article` for normal-flow custom-header reports, ASCII-strip for the fixed-dpi mm labels. | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
|
||||
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
|
||||
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
|
||||
@@ -630,8 +630,27 @@ De-Racking → Final inspection → Shipping`
|
||||
Columns are first-class — they always render in this exact order, never
|
||||
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
|
||||
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
|
||||
(stored) from `work_centre.area_kind` with a fallback to a step-kind
|
||||
dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
|
||||
(stored) in `_compute_area_kind` (`fusion_plating_jobs/models/fp_job_step.py`):
|
||||
`work_centre.area_kind` → else `recipe_node.kind_id.area_kind` (the
|
||||
`fp.step.kind` taxonomy is authoritative; the legacy `_STEP_KIND_TO_AREA`
|
||||
dict is gone) → else catch-all `'plating'`.
|
||||
|
||||
**Gating/"Ready for X" marker steps fall FORWARD (fixed 2026-06-02).** The
|
||||
`fp.step.kind` named *Gating* has `code='gating'` **and `area_kind='receiving'`**.
|
||||
A gating step is a non-physical "ready for the next stage" marker, so
|
||||
mapping it to Receiving made a *mid-recipe* gate snap the job's card back
|
||||
to the first column (Racking → "Ready for processing" jumped to Receiving,
|
||||
so the job looked like it vanished). `_compute_area_kind` therefore detects
|
||||
a gating step via the **stable `kind_id.code == 'gating'`** (never the
|
||||
display name) and resolves its column to the **next non-gating step's** raw
|
||||
area (so "Ready for processing" before plating shows in the **Plating**
|
||||
column); if nothing real follows, it falls back to the last real stage.
|
||||
Helpers: `_fp_is_gating_step`, `_fp_raw_area_kind` (own work_centre/kind
|
||||
only — no look-ahead, avoids recursion), `_fp_resolve_area_kind`. **NB:**
|
||||
`area_kind` is a STORED compute, so after changing this logic you must
|
||||
force-recompute existing rows (`env['fp.job.step'].search([])._compute_area_kind()`
|
||||
+ `flush_recordset(['area_kind'])` + commit) — a `-u`/restart alone leaves
|
||||
old values stale.
|
||||
|
||||
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
|
||||
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
|
||||
@@ -1847,20 +1866,42 @@ A 50-part job can have parts at several stages at once (10 Masking, 20 Plating,
|
||||
|
||||
3. **The Move Parts dialog was only wired into the DEPRECATED `shopfloor_tablet.js`** — the live `fp_job_workspace` had no move/advance action, so operators literally could not move partial parts. The "Send → <next>" action now lives in `job_workspace.js` (`getStepActions` advance descriptor → `onAdvanceStep` → `FpMovePartsDialog`). The dialog itself was slimmed (qty steppers, no keyboard; Transfer Type + To Location collapsed behind "More options"). If you add another operator surface, wire the advance action there too.
|
||||
|
||||
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves.
|
||||
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves. **Two non-obvious traps in `_fp_try_autofinish_on_drain` (both fixed 2026-06-02):** (1) it must guard on a real **OUTGOING** move (`move_ids` to a different step, `qty_moved > 0`), NOT `_fp_has_real_incoming()` — the FIRST/seeded stage (e.g. Racking) is fed by the `qty_at_step` seed, has no incoming move, and so never auto-finished when all its parts were sent forward. (2) It is **best-effort and gated**: `button_finish` still runs the required-step-input / sign-off / contract-review gates, so a step with an unrecorded required input (e.g. Racking's "Count the Parts") will NOT auto-finish on drain — it stays `in_progress` with `qty_at_step=0` ("running, 0 here → finish me") until the operator records the input and finishes. That's correct (can't complete a step missing compliance data); don't try to force auto-finish past the gates.
|
||||
|
||||
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream.
|
||||
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream. **Second, distinct trap (fixed 2026-06-02): the Move dialog's `_blockers_for_move` predecessor check must only flag unfinished steps STRICTLY BETWEEN `from_step` and `to_step` (`from_step.sequence < s.sequence < to_step.sequence`), NOT all steps before `to_step`.** The original `s.sequence < to_step.sequence` filter counted the `from_step` itself (which is in-progress *by definition* when you advance partial parts out of it) as an "unfinished predecessor" of the destination — so EVERY partial advance to a not-yet-started next step showed a hard "Predecessor not done: \<from_step\>" blocker and greyed out SEND (hit on WO-30061). The between-only rule allows the immediate-next advance, still blocks skip-ahead moves over incomplete intermediate stages, and leaves backward (rework) moves unblocked (empty range).
|
||||
|
||||
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty − qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
|
||||
|
||||
**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).
|
||||
|
||||
### Rollout fixes + open items (live operator testing, 2026-06-02)
|
||||
|
||||
Bugs that only real tablet testing surfaced (all fixed, deployed to entech, on main):
|
||||
- **Phantom future-stage cards** — a job showed in every not-yet-started `ready` stage. Presence keys off parked qty / `in_progress`, never `ready` (gotcha 1).
|
||||
- **Scan buttons** — camera button rendered two icons; "Scan Code" vs "Camera" was confusing. `QrScanner` keeps its single icon; now **"Scan QR"** (camera) + **"Enter Code"** (wedge/manual). Don't pass an emoji in the `QrScanner` label — it doubles the icon.
|
||||
- **Dark-mode invisible text** — `var(--bs-body-color)` / `var(--bs-secondary-color)` are UNDEFINED in Odoo's backend CSS → always fall back to the dark hex. Use inherit / translucent `rgba()` (see the Dark-mode SCSS section).
|
||||
- **Partial advance blocked by the from-step's own predecessor** — `_blockers_for_move` now blocks only steps STRICTLY BETWEEN from/to (gotcha 5).
|
||||
- **First/seeded stage never auto-finished on drain** — `_fp_try_autofinish_on_drain` guards on a real OUTGOING move, not incoming.
|
||||
- **Gating "Ready for X" steps zig-zagged the card back to Receiving** — gating steps fall FORWARD to the next real stage's column (see the Plant-View `area_kind` note).
|
||||
|
||||
Open / deferred (next session):
|
||||
- **Discoverability (not built):** show a "N here" qty badge on step rows + the count on the Send button; add a "✓ all sent — record inputs to finish" hint when a step is drained-to-0 but still has a pending required input (answers operators' "why is it still active?").
|
||||
- **Scrap / Rework as standalone intent buttons** — currently under the Move dialog's "More options"; only Hold has its own button.
|
||||
- **Automated tests NOT written** — modules need enterprise deps (can't install on local Community); validated via pyflakes/lxml + live odoo-shell verification on entech. A `bt_s*`-style battle test is the recommended next step.
|
||||
- **Plant-card status chips** read fine but bright in dark mode (deferred).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
1. **Odoo's compiled backend CSS does NOT define the Bootstrap colour custom-properties — `var(--bs-body-color)`, `var(--bs-secondary-color)`, `var(--bs-tertiary-bg)`, `var(--bs-body-bg)` are REFERENCED but never DEFINED (verified 2026-06-02: 0 definitions for `--bs-body-color`/`--bs-secondary-color` in the live `web.assets_backend` text).** So **any `color: var(--bs-body-color, #hex)` resolves to the `#hex` fallback in BOTH light and dark mode** — a dark hex → invisible on a dark surface. (`var(--text-secondary, …)` is even worse — that var name is entirely made-up.) Odoo themes the backend via **runtime `[data-bs-theme="dark"]`** (Bootstrap 5.3) + SCSS literals, NOT via those CSS vars, and NOT via `prefers-color-scheme`. Do NOT colour custom text with `var(--bs-*)`. **Correct, verified options:**
|
||||
- **Inherit** — omit `color:` entirely so the element takes the dialog/page theme colour. Proven: the finish-block dialog's title + `.o_fp_finish_block_list` items have no colour and ARE readable in both modes; the `.o_fp_finish_block_msg` line was the ONLY broken one because it set `color: var(--bs-body-color,…)`. Removing that one line fixed it. This is the simplest fix for dialog/modal text.
|
||||
- **Translucent `rgba()` for tinted boxes** — e.g. `background: rgba(245,158,11,0.16)` (warning) / `rgba(128,128,128,0.12)` (neutral). Works over whatever the live theme background is. (`color-mix(…, var(--bs-body-bg))` does NOT work — `--bs-body-bg` is undefined, so the whole `color-mix` is invalid and dropped.)
|
||||
- **Explicit `[data-bs-theme="dark"] .my-class { color: … }`** override with literal hex when you genuinely need a different value per theme.
|
||||
- **Compile-time `$o-webclient-color-scheme == dark`** literals only work if the **dark bundle is actually served**; on entech the active mechanism is runtime `[data-bs-theme]`, so prefer inherit / rgba / `[data-bs-theme=dark]` selectors over the two-bundle approach for backend dialogs.
|
||||
|
||||
NOTE: ~33 muted-text usages across `job_workspace.scss` + 5 component stylesheets still use `var(--bs-secondary-color, #hex)` (undefined → dark hex). They're muted/secondary so less glaring, but technically wrong in dark mode — sweep them to one of the patterns above when touched.
|
||||
2. **Odoo's bootstrap does NOT define the Bootstrap 5.3 `--bs-{color}-bg-subtle` / `--bs-{color}-text-emphasis` family.** Verified by grepping `web/static/lib/bootstrap/scss/_root.scss`: `--bs-tertiary-bg` and `--bs-secondary-color` exist; `--bs-warning-bg-subtle`, `--bs-danger-bg-subtle`, `--bs-warning-text-emphasis` are MISSING. So `var(--bs-warning-bg-subtle, #fef3c7)` just yields the bright hex fallback — useless for dark mode. **For tinted status banners (warning/danger/info), use `color-mix` over the live theme bg instead:** `background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg)); color: var(--bs-body-color);` — pale in light mode, dark-tinted in dark mode, readable in both, graceful-degrades to no-bg on ancient browsers. (`color-mix` works in `background-color` per the rule-8 note; keep it out of shorthands.) Solid accent elements (selected pills, priority dots) with `color: white` are fine as-is in both modes.
|
||||
3. **Confirmed-present, dark-aware Odoo vars to reach for:** `--bs-body-color` (primary text), `--bs-secondary-color` (muted text), `--bs-body-bg` / `--bs-tertiary-bg` (surfaces), `--bs-border-color`. The deliberate color-coded plant-card status chips (`_plant_card.scss` `.kind-*` / `.tag-*`) are light-bg + dark-text (readable in both modes, just bright on a dark card) — intentionally left as a color-coded set.
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
# Multi-Rack Splitting at Racking — Phase 1 Implementation Plan
|
||||
|
||||
> **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:** Let an operator split a single work order's parts across multiple physical racks at the Racking step (default 1 rack with all parts; "+ Add Rack" divides equally; manual qty override), and move each rack independently through Plating → Baking → De-Racking, reusing the existing move log.
|
||||
|
||||
**Architecture:** A new first-class `fp.rack.load` record (+ `fp.rack.load.line` per work order) represents "parts on one rack." It carries its own workflow position and moves via the existing `fp.job.step.move` chain-of-custody log (one move row per line). Phase 1 is single-WO (one line per load); grouping is Phase 2. The UI is a Racking panel on the Job Workspace (mirrors the existing Receiving card).
|
||||
|
||||
**Tech Stack:** Odoo 19 (Python models + TransactionCase tests), OWL 2 (JS/XML/SCSS), JSONRPC controllers. Spec: `docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md`.
|
||||
|
||||
**Test command (local dev, Community):**
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_plating \
|
||||
-u fusion_plating --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Confirm integration field names (no code; grep only)
|
||||
|
||||
The plan references fields on existing models. Confirm exact names before writing code so later tasks reference real symbols.
|
||||
|
||||
- [ ] **Step 1: Confirm the job's recipe field + step/move/rack fields**
|
||||
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules/fusion_plating
|
||||
grep -nE "recipe.*fields\.|_name = ['\"]fp\.job['\"]" fusion_plating_jobs/models/fp_job.py | head
|
||||
grep -nE "area_kind|qty_at_step|qty_at_step_start|qty_at_step_finish|rack_id|requires_rack_assignment" fusion_plating/models/fp_job_step.py | head
|
||||
grep -nE "qty_moved|transfer_type|to_step_id|from_step_id|rack_id|move_datetime|moved_by_user_id" fusion_plating/models/fp_job_step_move.py | head
|
||||
grep -nE "capacity|capacity_count|racking_state" fusion_plating/models/fp_rack.py | head
|
||||
grep -nE "area_kind" fusion_plating_jobs/models/fp_job_step.py | head
|
||||
```
|
||||
|
||||
Record the confirmed names. **If `fp.job` has no direct `recipe_id`, use the field that resolves the recipe (check `fp_job.py` for `recipe_id` / `x_fc_recipe_id` / a compute).** The plan below assumes:
|
||||
- `fp.job.recipe_id` (Many2one to the recipe node/header) — **substitute the real name everywhere if different.**
|
||||
- `fp.job.step.area_kind`, `fp.job.step.qty_at_step`, `fp.job.step.qty_at_step_start/finish`, `fp.job.step.rack_id`.
|
||||
- `fp.job.step.move`: `job_id, from_step_id, to_step_id, qty_moved, rack_id, transfer_type, moved_by_user_id, move_datetime`.
|
||||
- `fusion.plating.rack`: `capacity`, `capacity_count`, `racking_state`.
|
||||
|
||||
- [ ] **Step 2: Confirm the area_kind column sequence** (for "least-advanced" later)
|
||||
|
||||
```bash
|
||||
grep -n "_COLUMN_LABELS\|_COLUMN_SEQUENCE" fusion_plating_shopfloor/controllers/plant_kanban.py fusion_plating_jobs/models/fp_job.py
|
||||
```
|
||||
Record the ordered list `[receiving, masking, blasting, racking, plating, baking, de_racking, inspection, shipping]`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `fp.rack.load` + `fp.rack.load.line` models + sequence + ACL
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating/models/fp_rack_load.py`
|
||||
- Modify: `fusion_plating/models/__init__.py` (add `from . import fp_rack_load`)
|
||||
- Create: `fusion_plating/data/fp_rack_load_sequence.xml`
|
||||
- Modify: `fusion_plating/security/ir.model.access.csv` (add rows)
|
||||
- Modify: `fusion_plating/__manifest__.py` (add data file, bump version)
|
||||
- Test: `fusion_plating/tests/test_rack_load.py` (+ register in `tests/__init__.py`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test (model exists + qty_total compute + sequence)**
|
||||
|
||||
```python
|
||||
# fusion_plating/tests/test_rack_load.py
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestRackLoad(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Load = self.env['fp.rack.load']
|
||||
self.rack = self.env['fusion.plating.rack'].create({'name': 'TST-RACK-1', 'capacity': 60})
|
||||
# A minimal job + racking step. Use existing helpers if present;
|
||||
# otherwise create a bare job. Adjust required fields per fp.job.
|
||||
self.job = self.env['fp.job'].create({'name': 'WO-TEST-1', 'qty': 100})
|
||||
|
||||
def test_create_and_qty_total(self):
|
||||
load = self.Load.create({
|
||||
'rack_id': self.rack.id,
|
||||
'line_ids': [(0, 0, {'job_id': self.job.id, 'qty': 40})],
|
||||
})
|
||||
self.assertTrue(load.name.startswith('RACKLOAD/'))
|
||||
self.assertEqual(load.qty_total, 40)
|
||||
self.assertEqual(load.state, 'loading')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it — expect FAIL** (`KeyError: 'fp.rack.load'`). Command: the Test command above, `--test-tags /fusion_plating:TestRackLoad`.
|
||||
|
||||
- [ ] **Step 3: Implement the models**
|
||||
|
||||
```python
|
||||
# fusion_plating/models/fp_rack_load.py
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
|
||||
class FpRackLoad(models.Model):
|
||||
_name = 'fp.rack.load'
|
||||
_description = 'Rack Load (parts on one physical rack)'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'id desc'
|
||||
|
||||
name = fields.Char(string='Reference', required=True, copy=False,
|
||||
default=lambda self: _('New'))
|
||||
rack_id = fields.Many2one('fusion.plating.rack', string='Rack',
|
||||
required=True, tracking=True)
|
||||
line_ids = fields.One2many('fp.rack.load.line', 'load_id', string='Work Orders')
|
||||
qty_total = fields.Integer(string='Total Parts', compute='_compute_qty_total',
|
||||
store=True)
|
||||
current_step_id = fields.Many2one('fp.job.step', string='Current Step', tracking=True)
|
||||
current_area_kind = fields.Char(string='Current Area',
|
||||
compute='_compute_current_area_kind', store=True)
|
||||
state = fields.Selection([
|
||||
('loading', 'Loading'), ('loaded', 'Loaded'),
|
||||
('running', 'Running'), ('unracked', 'Unracked'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], default='loading', required=True, tracking=True)
|
||||
tag_ids = fields.Many2many('fp.rack.tag', string='Tags')
|
||||
company_id = fields.Many2one('res.company', default=lambda s: s.env.company)
|
||||
|
||||
@api.depends('line_ids.qty')
|
||||
def _compute_qty_total(self):
|
||||
for load in self:
|
||||
load.qty_total = sum(load.line_ids.mapped('qty'))
|
||||
|
||||
@api.depends('current_step_id.area_kind')
|
||||
def _compute_current_area_kind(self):
|
||||
for load in self:
|
||||
load.current_area_kind = load.current_step_id.area_kind or False
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fp.rack.load') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
_qty_total_positive = models.Constraint(
|
||||
'CHECK (qty_total >= 0)', 'Rack load quantity cannot be negative.')
|
||||
|
||||
|
||||
class FpRackLoadLine(models.Model):
|
||||
_name = 'fp.rack.load.line'
|
||||
_description = 'Rack Load Line (one work order on a rack)'
|
||||
|
||||
load_id = fields.Many2one('fp.rack.load', required=True, ondelete='cascade')
|
||||
job_id = fields.Many2one('fp.job', string='Work Order', required=True)
|
||||
qty = fields.Integer(string='Parts', required=True, default=0)
|
||||
part_catalog_id = fields.Many2one(related='job_id.part_catalog_id', store=True)
|
||||
|
||||
_qty_positive = models.Constraint(
|
||||
'CHECK (qty >= 0)', 'Line quantity cannot be negative.')
|
||||
```
|
||||
|
||||
Sequence:
|
||||
```xml
|
||||
<!-- fusion_plating/data/fp_rack_load_sequence.xml -->
|
||||
<odoo>
|
||||
<record id="seq_fp_rack_load" model="ir.sequence">
|
||||
<field name="name">Rack Load</field>
|
||||
<field name="code">fp.rack.load</field>
|
||||
<field name="prefix">RACKLOAD/%(year)s/</field>
|
||||
<field name="padding">4</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
ACL rows (append to `fusion_plating/security/ir.model.access.csv`) — Technician r/w/c, Manager full:
|
||||
```csv
|
||||
access_fp_rack_load_tech,fp.rack.load.tech,model_fp_rack_load,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_rack_load_mgr,fp.rack.load.mgr,model_fp_rack_load,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_rack_load_line_tech,fp.rack.load.line.tech,model_fp_rack_load_line,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_rack_load_line_mgr,fp.rack.load.line.mgr,model_fp_rack_load_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
```
|
||||
Add `'data/fp_rack_load_sequence.xml'` to `__manifest__.py` `data`, bump `version`. Register the test in `tests/__init__.py`.
|
||||
|
||||
- [ ] **Step 4: Run the test — expect PASS.**
|
||||
- [ ] **Step 5: Commit** — `git add fusion_plating/models/fp_rack_load.py fusion_plating/data/fp_rack_load_sequence.xml fusion_plating/security/ir.model.access.csv fusion_plating/tests/test_rack_load.py fusion_plating/models/__init__.py fusion_plating/tests/__init__.py fusion_plating/__manifest__.py && git commit -m "feat(fusion_plating): add fp.rack.load + line models (racking phase 1)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Division API — add_rack / divide_equally / set_qty / remove_rack
|
||||
|
||||
Pure quantity math operating on a job's set of rack-loads at the racking step. This is the heart of the feature; full code + tests.
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating/models/fp_rack_load.py` (add class methods)
|
||||
- Test: `fusion_plating/tests/test_rack_load.py` (add cases)
|
||||
|
||||
- [ ] **Step 1: Write failing tests for the division math (D4: remainder to first racks)**
|
||||
|
||||
```python
|
||||
def _mk_loads(self, n, total):
|
||||
"""Helper: split `total` parts of self.job across n loads equally."""
|
||||
return self.env['fp.rack.load']._fp_split_job(self.job, total, n)
|
||||
|
||||
def test_divide_two_is_50_50(self):
|
||||
loads = self._mk_loads(2, 100)
|
||||
self.assertEqual(sorted(loads.mapped('qty_total')), [50, 50])
|
||||
|
||||
def test_divide_three_remainder_to_first(self):
|
||||
loads = self._mk_loads(3, 100)
|
||||
self.assertEqual(loads.mapped('qty_total'), [34, 33, 33])
|
||||
|
||||
def test_divide_four_equal(self):
|
||||
loads = self._mk_loads(4, 100)
|
||||
self.assertEqual(loads.mapped('qty_total'), [25, 25, 25, 25])
|
||||
|
||||
def test_add_rack_redivides(self):
|
||||
loads = self._mk_loads(1, 100)
|
||||
self.assertEqual(loads.mapped('qty_total'), [100])
|
||||
loads2 = self.env['fp.rack.load']._fp_add_rack(self.job)
|
||||
self.assertEqual(sorted(loads2.mapped('qty_total')), [50, 50])
|
||||
|
||||
def test_set_qty_manual_and_unassigned(self):
|
||||
loads = self._mk_loads(2, 100) # 50/50
|
||||
loads[0]._fp_set_qty(70)
|
||||
# one load now 70; total assigned 120 must be rejected (> available)
|
||||
with self.assertRaises(UserError):
|
||||
loads[1]._fp_set_qty(50) # 70+50 > 100
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`_fp_split_job` undefined).
|
||||
|
||||
- [ ] **Step 3: Implement the division API**
|
||||
|
||||
```python
|
||||
@api.model
|
||||
def _fp_equal_split(self, total, n):
|
||||
"""Return a list of n ints summing to total; remainder to the first racks (D4)."""
|
||||
if n < 1:
|
||||
return []
|
||||
base, rem = divmod(int(total), n)
|
||||
return [base + 1 if i < rem else base for i in range(n)]
|
||||
|
||||
@api.model
|
||||
def _fp_racking_step_for(self, job):
|
||||
"""The job's Racking step (the parts source). Adjust the lookup to the
|
||||
real racking-step detection (_fp_is_racking_step)."""
|
||||
steps = job.step_ids if 'step_ids' in job._fields else \
|
||||
self.env['fp.job.step'].search([('job_id', '=', job.id)])
|
||||
return steps.filtered(lambda s: s._fp_is_racking_step())[:1]
|
||||
|
||||
@api.model
|
||||
def _fp_racking_total(self, job):
|
||||
"""Total parts available to rack for this job."""
|
||||
step = self._fp_racking_step_for(job)
|
||||
return int(step.qty_at_step) if step else int(job.qty)
|
||||
|
||||
@api.model
|
||||
def _fp_job_loads(self, job):
|
||||
return self.search([
|
||||
('line_ids.job_id', '=', job.id),
|
||||
('state', 'in', ('loading', 'loaded')),
|
||||
])
|
||||
|
||||
@api.model
|
||||
def _fp_split_job(self, job, total, n):
|
||||
"""Create n fresh loads for `job` summing to `total`, equal split."""
|
||||
existing = self._fp_job_loads(job)
|
||||
existing.filtered(lambda l: not l.current_step_id).unlink()
|
||||
qtys = self._fp_equal_split(total, n)
|
||||
loads = self.env['fp.rack.load']
|
||||
for q in qtys:
|
||||
loads |= self.create({'line_ids': [(0, 0, {'job_id': job.id, 'qty': q})]})
|
||||
return loads
|
||||
|
||||
@api.model
|
||||
def _fp_add_rack(self, job):
|
||||
"""Add one rack and re-divide equally across all of the job's loads."""
|
||||
total = self._fp_racking_total(job)
|
||||
n = len(self._fp_job_loads(job)) + 1
|
||||
return self._fp_split_job(job, total, max(n, 1))
|
||||
|
||||
@api.model
|
||||
def _fp_divide_equally(self, job):
|
||||
total = self._fp_racking_total(job)
|
||||
n = max(len(self._fp_job_loads(job)), 1)
|
||||
return self._fp_split_job(job, total, n)
|
||||
|
||||
def _fp_set_qty(self, qty):
|
||||
"""Manual override of a single load's qty. Reject if it pushes the job's
|
||||
total assigned over the available parts."""
|
||||
self.ensure_one()
|
||||
line = self.line_ids[:1]
|
||||
if not line:
|
||||
raise UserError(_('This rack has no work order line.'))
|
||||
job = line.job_id
|
||||
total = self.env['fp.rack.load']._fp_racking_total(job)
|
||||
other = sum(self.env['fp.rack.load']._fp_job_loads(job).filtered(
|
||||
lambda l: l != self).mapped('qty_total'))
|
||||
if other + int(qty) > total:
|
||||
raise UserError(_('Assigned %(a)s exceeds available %(t)s parts.')
|
||||
% {'a': other + int(qty), 't': total})
|
||||
line.qty = int(qty)
|
||||
|
||||
def _fp_remove_rack(self):
|
||||
self.ensure_one()
|
||||
if self.current_step_id:
|
||||
raise UserError(_('Cannot remove a rack that has already moved.'))
|
||||
self.unlink()
|
||||
```
|
||||
|
||||
> Note: `_fp_racking_step_for` calls `_fp_is_racking_step()` (exists on `fp.job.step` in `fusion_plating_jobs`). `fp.rack.load` lives in `fusion_plating`, which loads before `fusion_plating_jobs`; guard with `if hasattr(step, '_fp_is_racking_step')` or move these helpers to a thin model extension in `fusion_plating_jobs`. **Decide at Task 0:** if `_fp_is_racking_step` isn't importable from core, put Task 2's `_fp_racking_step_for/_fp_racking_total` on an `fp.rack.load` extension in `fusion_plating_jobs/models/` instead.
|
||||
|
||||
- [ ] **Step 4: Run tests — expect PASS.**
|
||||
- [ ] **Step 5: Commit** — `git commit -am "feat(fusion_plating): rack-load division API (equal split + manual override)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `fp.job` integration — qty_racked / qty_unracked
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating_jobs/models/fp_job_rack.py` (or add to `fp_job.py`)
|
||||
- Modify: `fusion_plating_jobs/models/__init__.py`
|
||||
- Test: `fusion_plating_jobs/tests/test_job_rack.py`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestJobRack(TransactionCase):
|
||||
def test_qty_racked_unracked(self):
|
||||
rack = self.env['fusion.plating.rack'].create({'name': 'R1', 'capacity': 60})
|
||||
job = self.env['fp.job'].create({'name': 'WO-X', 'qty': 100})
|
||||
self.env['fp.rack.load']._fp_split_job(job, 100, 2) # 50/50
|
||||
self.assertEqual(job.qty_racked, 100)
|
||||
self.assertEqual(job.qty_unracked, 0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL** (`qty_racked` undefined).
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
# fusion_plating_jobs/models/fp_job_rack.py
|
||||
from odoo import api, fields, models
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
rack_load_line_ids = fields.One2many('fp.rack.load.line', 'job_id',
|
||||
string='Rack Loads')
|
||||
qty_racked = fields.Integer(compute='_compute_qty_racked')
|
||||
qty_unracked = fields.Integer(compute='_compute_qty_racked')
|
||||
|
||||
@api.depends('rack_load_line_ids.qty', 'rack_load_line_ids.load_id.state')
|
||||
def _compute_qty_racked(self):
|
||||
for job in self:
|
||||
active = job.rack_load_line_ids.filtered(
|
||||
lambda l: l.load_id.state in ('loading', 'loaded', 'running'))
|
||||
job.qty_racked = sum(active.mapped('qty'))
|
||||
total = self.env['fp.rack.load']._fp_racking_total(job)
|
||||
job.qty_unracked = max(total - job.qty_racked, 0)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — PASS.** **Step 5: Commit.**
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Independent movement + De-Racking unrack
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_jobs/models/fp_job_rack.py` (movement methods on `fp.rack.load` via `_inherit`)
|
||||
- Test: `fusion_plating_jobs/tests/test_job_rack.py` (add cases)
|
||||
|
||||
- [ ] **Step 1: Failing test (advance a load → creates per-line moves + sets position)**
|
||||
|
||||
```python
|
||||
def test_advance_load_creates_move(self):
|
||||
job = self.env['fp.job'].create({'name': 'WO-Y', 'qty': 60})
|
||||
# need two steps: racking + plating. Build via the job's recipe/steps;
|
||||
# for the unit test, create two fp.job.step rows directly.
|
||||
Step = self.env['fp.job.step']
|
||||
s_rack = Step.create({'job_id': job.id, 'name': 'Racking', 'sequence': 30})
|
||||
s_plate = Step.create({'job_id': job.id, 'name': 'Plating', 'sequence': 40})
|
||||
load = self.env['fp.rack.load']._fp_split_job(job, 60, 1)
|
||||
load.current_step_id = s_rack
|
||||
load._fp_advance_to(s_plate)
|
||||
self.assertEqual(load.current_step_id, s_plate)
|
||||
self.assertEqual(load.state, 'running')
|
||||
mv = self.env['fp.job.step.move'].search([('rack_id', '=', load.rack_id.id)])
|
||||
self.assertTrue(mv)
|
||||
self.assertEqual(mv[0].qty_moved, 60)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL.**
|
||||
- [ ] **Step 3: Implement movement on `fp.rack.load`**
|
||||
|
||||
```python
|
||||
class FpRackLoad(models.Model):
|
||||
_inherit = 'fp.rack.load'
|
||||
|
||||
def _fp_advance_to(self, to_step):
|
||||
"""Move this rack-load to `to_step`, writing one move row per line."""
|
||||
Move = self.env['fp.job.step.move']
|
||||
for load in self:
|
||||
from_step = load.current_step_id
|
||||
for line in load.line_ids:
|
||||
Move.create({
|
||||
'job_id': line.job_id.id,
|
||||
'from_step_id': from_step.id if from_step else False,
|
||||
'to_step_id': to_step.id,
|
||||
'qty_moved': line.qty,
|
||||
'rack_id': load.rack_id.id,
|
||||
'transfer_type': 'step',
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
load.current_step_id = to_step
|
||||
load.state = 'running'
|
||||
|
||||
def _fp_unrack(self):
|
||||
"""De-Racking: free the rack, mark unracked. Each line's parts continue
|
||||
in their own job's flow (the moves already attributed qty per job)."""
|
||||
for load in self:
|
||||
load.state = 'unracked'
|
||||
if load.rack_id:
|
||||
load.rack_id.racking_state = 'empty'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — PASS.** **Step 5: Commit.**
|
||||
|
||||
> Reuse the existing **Move Rack** tablet dialog for the operator-facing single/multi move; `_fp_advance_to` is the model API those endpoints call. The de-racking trigger: call `_fp_unrack()` from the De-Racking step's finish (wire in Task 6 controller or a `button_finish` hook — keep it in the controller for Phase 1).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Controllers `/fp/racking/*`
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating_shopfloor/controllers/racking_controller.py`
|
||||
- Modify: `fusion_plating_shopfloor/controllers/__init__.py`
|
||||
- Test: manual (controller smoke via the panel in Task 6); optional python smoke with `pyflakes`.
|
||||
|
||||
- [ ] **Step 1: Implement endpoints** (JSONRPC, auth='user', run as the technician)
|
||||
|
||||
```python
|
||||
# fusion_plating_shopfloor/controllers/racking_controller.py
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class FpRackingController(http.Controller):
|
||||
|
||||
def _job(self, job_id):
|
||||
return request.env['fp.job'].browse(int(job_id))
|
||||
|
||||
def _load_payload(self, job):
|
||||
Load = request.env['fp.rack.load']
|
||||
loads = Load._fp_job_loads(job)
|
||||
total = Load._fp_racking_total(job)
|
||||
return {
|
||||
'ok': True,
|
||||
'job_id': job.id,
|
||||
'wo_name': job.display_wo_name,
|
||||
'total': total,
|
||||
'unassigned': max(total - sum(loads.mapped('qty_total')), 0),
|
||||
'loads': [{
|
||||
'id': l.id, 'name': l.name,
|
||||
'rack_id': l.rack_id.id, 'rack_name': l.rack_id.name or '',
|
||||
'rack_capacity': l.rack_id.capacity or 0,
|
||||
'qty': l.qty_total,
|
||||
'over_capacity': bool(l.rack_id.capacity and l.qty_total > l.rack_id.capacity),
|
||||
'moved': bool(l.current_step_id),
|
||||
} for l in loads],
|
||||
}
|
||||
|
||||
@http.route('/fp/racking/load', type='jsonrpc', auth='user')
|
||||
def load(self, job_id):
|
||||
return self._load_payload(self._job(job_id))
|
||||
|
||||
@http.route('/fp/racking/add_rack', type='jsonrpc', auth='user')
|
||||
def add_rack(self, job_id):
|
||||
job = self._job(job_id)
|
||||
try:
|
||||
request.env['fp.rack.load']._fp_add_rack(job)
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0])}
|
||||
return self._load_payload(job)
|
||||
|
||||
@http.route('/fp/racking/divide_equally', type='jsonrpc', auth='user')
|
||||
def divide_equally(self, job_id):
|
||||
job = self._job(job_id)
|
||||
request.env['fp.rack.load']._fp_divide_equally(job)
|
||||
return self._load_payload(job)
|
||||
|
||||
@http.route('/fp/racking/set_qty', type='jsonrpc', auth='user')
|
||||
def set_qty(self, load_id, qty):
|
||||
load = request.env['fp.rack.load'].browse(int(load_id))
|
||||
try:
|
||||
load._fp_set_qty(qty)
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0])}
|
||||
return self._load_payload(load.line_ids[:1].job_id)
|
||||
|
||||
@http.route('/fp/racking/remove_rack', type='jsonrpc', auth='user')
|
||||
def remove_rack(self, load_id):
|
||||
load = request.env['fp.rack.load'].browse(int(load_id))
|
||||
job = load.line_ids[:1].job_id
|
||||
try:
|
||||
load._fp_remove_rack()
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0])}
|
||||
return self._load_payload(job)
|
||||
|
||||
@http.route('/fp/racking/assign_rack', type='jsonrpc', auth='user')
|
||||
def assign_rack(self, load_id, rack_id):
|
||||
load = request.env['fp.rack.load'].browse(int(load_id))
|
||||
rack = request.env['fusion.plating.rack'].browse(int(rack_id))
|
||||
load.rack_id = rack.id
|
||||
rack.racking_state = 'loaded'
|
||||
return self._load_payload(load.line_ids[:1].job_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: pyflakes** — `docker exec odoo-modsdev-app python3 -m pyflakes <file>` → no undefined names. **Step 3: Commit.**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Job Workspace Racking panel (OWL)
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating_shopfloor/static/src/js/components/racking_panel.js`
|
||||
- Create: `fusion_plating_shopfloor/static/src/xml/components/racking_panel.xml`
|
||||
- Create: `fusion_plating_shopfloor/static/src/scss/components/_racking_panel.scss`
|
||||
- Modify: `fusion_plating_shopfloor/static/src/xml/job_workspace.xml` (render `<RackingPanel>` when the WO is at the racking step)
|
||||
- Modify: `fusion_plating_shopfloor/static/src/js/job_workspace.js` (import + register the component; pass `jobId`)
|
||||
- Modify: `fusion_plating_shopfloor/__manifest__.py` (register the 3 asset files; bump version)
|
||||
|
||||
- [ ] **Step 1: Implement the OWL component** (standalone, `rpc` from `@web/core/network/rpc`, `static props`)
|
||||
|
||||
```javascript
|
||||
/** @odoo-module **/
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
export class RackingPanel extends Component {
|
||||
static template = "fusion_plating_shopfloor.RackingPanel";
|
||||
static props = ["jobId"];
|
||||
setup() {
|
||||
this.state = useState({ data: null, error: "" });
|
||||
onWillStart(() => this.reload());
|
||||
}
|
||||
async reload() {
|
||||
const d = await rpc("/fp/racking/load", { job_id: this.props.jobId });
|
||||
if (d.ok) this.state.data = d; else this.state.error = d.error || "";
|
||||
}
|
||||
async addRack() { this._apply(await rpc("/fp/racking/add_rack", { job_id: this.props.jobId })); }
|
||||
async divideEqually() { this._apply(await rpc("/fp/racking/divide_equally", { job_id: this.props.jobId })); }
|
||||
async setQty(load, ev) {
|
||||
const qty = parseInt(ev.target.value, 10) || 0;
|
||||
this._apply(await rpc("/fp/racking/set_qty", { load_id: load.id, qty }));
|
||||
}
|
||||
async removeRack(load) { this._apply(await rpc("/fp/racking/remove_rack", { load_id: load.id })); }
|
||||
_apply(d) { if (d.ok) this.state.data = d; else this.state.error = d.error || ""; }
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- racking_panel.xml -->
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_plating_shopfloor.RackingPanel">
|
||||
<div class="o_fp_racking_panel" t-if="state.data">
|
||||
<div class="o_fp_rkp_head">
|
||||
<span class="o_fp_rkp_title">🧰 Racking</span>
|
||||
<span class="o_fp_rkp_unassigned" t-att-class="state.data.unassigned ? 'has' : ''">
|
||||
Unassigned: <t t-esc="state.data.unassigned"/> / <t t-esc="state.data.total"/>
|
||||
</span>
|
||||
</div>
|
||||
<div t-if="state.error" class="o_fp_rkp_err" t-esc="state.error"/>
|
||||
<t t-foreach="state.data.loads" t-as="load" t-key="load.id">
|
||||
<div t-att-class="'o_fp_rkp_row' + (load.over_capacity ? ' over' : '')">
|
||||
<span class="o_fp_rkp_rack" t-esc="load.rack_name || 'No rack'"/>
|
||||
<input type="number" inputmode="numeric" class="form-control o_fp_rkp_qty"
|
||||
t-att-value="load.qty" t-att-disabled="load.moved"
|
||||
t-on-change="(ev) => this.setQty(load, ev)"/>
|
||||
<span class="o_fp_rkp_cap" t-if="load.rack_capacity">
|
||||
/ <t t-esc="load.rack_capacity"/>
|
||||
</span>
|
||||
<button class="btn btn-sm btn-light" t-att-disabled="load.moved"
|
||||
t-on-click="() => this.removeRack(load)">✕</button>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_fp_rkp_actions">
|
||||
<button class="btn btn-primary" t-on-click="addRack">+ Add Rack</button>
|
||||
<button class="btn btn-light" t-on-click="divideEqually">Divide Equally</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
```
|
||||
|
||||
SCSS: card surface using existing `$_ws-*` tokens (mirror `.o_fp_ws_rcv`); over-capacity row gets an amber left border. Register `RackingPanel` in `job_workspace.js` `components` and render it in the steps area when `state.data.job.is_at_racking` (add that flag to the workspace `/fp/workspace/load` payload, or check the active step's `area_kind === 'racking'`).
|
||||
|
||||
- [ ] **Step 2: Register assets + bump version.** **Step 3: Manual smoke** (see Task 7). **Step 4: Commit.**
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Local deploy + manual smoke + verify
|
||||
|
||||
- [ ] **Step 1: Update + clear assets on local dev**
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -30
|
||||
```
|
||||
Expected: no ERROR/Traceback; "Modules loaded."
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_plating,/fusion_plating_jobs \
|
||||
-u fusion_plating,fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 3: Manual smoke (browser, http://localhost:8082):** open a WO at Racking in the Job Workspace → Racking panel shows 1 rack with all parts → +Add Rack → 50/50 → +Add Rack → 34/33/33 → edit a qty → Unassigned updates → assign a rack → move (via existing Move Rack) and confirm the load advances independently.
|
||||
|
||||
- [ ] **Step 4: Commit** any fixes. Do NOT deploy to entech yet — entech deploy is a separate, explicitly-confirmed step (new models + migration on a live DB).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** §3 model → Task 1; §4 division → Task 2; §3.3 job fields → Task 3; §5 movement + de-racking → Task 4; §7.3 endpoints → Task 5; §7.1 Job Workspace panel → Task 6. Phase-1 scope only (single WO / one line per load); §6 grouping + §7.2 station screen + §8 Plant Kanban are **Phase 2/3 (separate plans)** — intentionally deferred.
|
||||
- **Placeholder scan:** the only deferred specifics are the confirmed field names (Task 0) and the racking-step lookup location (flagged in Task 2). No "TODO/handle edge cases" hand-waving in code steps.
|
||||
- **Type consistency:** `_fp_split_job`, `_fp_add_rack`, `_fp_divide_equally`, `_fp_set_qty`, `_fp_remove_rack`, `_fp_advance_to`, `_fp_unrack`, `_fp_job_loads`, `_fp_racking_total` used consistently across Tasks 2–6; controller calls match.
|
||||
|
||||
## Notes for entech deployment (after local green)
|
||||
- New models → `-u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor` on entech (creates tables, no destructive migration).
|
||||
- Existing single `fp.job.step.rack_id` flow is untouched (back-compat).
|
||||
@@ -0,0 +1,412 @@
|
||||
# Shop-Floor Sign-Off: Reuse Saved Plating Signature — Implementation Plan
|
||||
|
||||
> **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:** Make shop-floor step sign-off reuse the operator's saved Plating Signature (one-tap confirm) instead of redrawing every time; capture-and-persist it the first time.
|
||||
|
||||
**Architecture:** The `/fp/workspace/load` payload exposes whether the user has a Plating Signature + the image; `job_workspace.js` shows a confirm-with-preview dialog when they do (new `FpSignatureConfirm`) and the existing `FpSignaturePad` when they don't; `/fp/workspace/sign_off` persists any drawing to `res.users.x_fc_signature_image` and drops the wasted per-step attachment.
|
||||
|
||||
**Tech Stack:** Odoo 19 (`fusion_plating_shopfloor`), OWL components, JSON-RPC controller, `HttpCase` tests.
|
||||
|
||||
---
|
||||
|
||||
## Working location (IMPORTANT — isolated worktree)
|
||||
|
||||
All work happens in the worktree **`K:\Github\Odoo-Modules-signoff-wt`** on branch **`feat/shopfloor-signoff-reuse-signature`** (off `main`). Use absolute paths under that dir for Read/Edit; for git use `git -C "K:\Github\Odoo-Modules-signoff-wt" ...` (tracked prefix `fusion_plating/`). The main checkout is in use by another session — do not touch it.
|
||||
|
||||
## Testing model
|
||||
|
||||
`fusion_plating_shopfloor` can't install on the local Community box — the `HttpCase` tests run on an Enterprise env (entech clone), like the WO-grouping deploy. Local per-task gate:
|
||||
- Python: `python -m pyflakes "<file>"` (host).
|
||||
- XML: `python -c "import xml.etree.ElementTree as ET; ET.parse(r'<file>'); print('XML OK')"`.
|
||||
- JS (ESM): `node --check` rejects `import` on a `.js`; copy to a temp `.mjs` first: `Copy-Item <file> $env:TEMP\x.mjs; node --check $env:TEMP\x.mjs` (skip if `node` absent — the asset-bundle compile during the clone-verify `-u` is the real gate).
|
||||
- SCSS: no local check; Odoo compiles it on `-u` (clone-verify catches errors).
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Module | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `fusion_plating_shopfloor/controllers/workspace_controller.py` | shopfloor | `load` payload keys; `sign_off` persist + drop attachment. |
|
||||
| `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` | shopfloor | NEW confirm dialog component. |
|
||||
| `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` | shopfloor | NEW template. |
|
||||
| `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` | shopfloor | NEW styling. |
|
||||
| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | shopfloor | confirm-vs-draw wiring. |
|
||||
| `fusion_plating_shopfloor/__manifest__.py` | shopfloor | register 3 assets + version bump. |
|
||||
| `fusion_plating_shopfloor/tests/test_workspace_controller.py` | shopfloor | new HttpCase tests. |
|
||||
|
||||
**Build order:** backend (payload + sign_off + tests) → new component + manifest → workspace wiring → version bump + static checks → clone-verify.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — load payload + sign_off rewrite + tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_shopfloor/controllers/workspace_controller.py` (load return dict ~line 241; `sign_off` ~line 450-494)
|
||||
- Test: `fusion_plating_shopfloor/tests/test_workspace_controller.py`
|
||||
|
||||
- [ ] **Step 1: Add the load payload keys.** In `workspace_controller.py`, the `load` method's `return {` dict starts with `'ok': True,` (around line 241-242). Insert these two keys immediately after the `'ok': True,` line, at the same indentation:
|
||||
|
||||
```python
|
||||
'user_has_plating_signature': bool(env.user.x_fc_signature_image),
|
||||
'user_plating_signature': (
|
||||
('data:image/png;base64,%s' % env.user.x_fc_signature_image.decode())
|
||||
if env.user.x_fc_signature_image else ''
|
||||
),
|
||||
```
|
||||
|
||||
(`env` is already bound at the top of `load`. `x_fc_signature_image` is in `SELF_READABLE_FIELDS`, so reading `env.user`'s own value is allowed.)
|
||||
|
||||
- [ ] **Step 2: Rewrite `sign_off`.** Replace the entire `sign_off` method (the `@http.route('/fp/workspace/sign_off', ...)` decorator + method, lines ~450-494) with:
|
||||
|
||||
```python
|
||||
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
||||
def sign_off(self, step_id, signature_data_uri=None):
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': f'Step {step_id} not found'}
|
||||
|
||||
sig = (signature_data_uri or '').strip()
|
||||
user = env.user
|
||||
if sig:
|
||||
# A drawing was supplied (first-time, or "use a different
|
||||
# signature"). Persist it as the user's Plating Signature so
|
||||
# every future sign-off + report reuses it. x_fc_signature_image
|
||||
# is in SELF_WRITEABLE_FIELDS, so writing one's own is allowed.
|
||||
if ',' in sig and sig.startswith('data:'):
|
||||
sig = sig.split(',', 1)[1]
|
||||
try:
|
||||
user.write({'x_fc_signature_image': sig})
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"workspace/sign_off: persisting Plating Signature failed for uid %s",
|
||||
env.uid,
|
||||
)
|
||||
return {'ok': False, 'error': 'Failed to save your signature.'}
|
||||
elif not user.x_fc_signature_image:
|
||||
# No drawing AND no saved signature — nothing to sign with.
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'A signature is required. Draw one to continue.',
|
||||
}
|
||||
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/sign_off: button_finish failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
|
||||
return {'ok': True, 'step_id': step.id, 'state': step.state}
|
||||
```
|
||||
|
||||
(Note: `signature_data_uri` is now optional; the per-step `ir.attachment` create is gone.)
|
||||
|
||||
- [ ] **Step 3: Write the tests.** Append to `fusion_plating_shopfloor/tests/test_workspace_controller.py` (the file already defines `_rpc`, `_TINY_PNG_B64`, and the `@tagged` decorator at the top — reuse them):
|
||||
|
||||
```python
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceSignOff(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/SIG001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 3,
|
||||
})
|
||||
|
||||
def test_load_exposes_plating_signature_flags(self):
|
||||
self.env.user.x_fc_signature_image = False
|
||||
res = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
|
||||
self.assertFalse(res['user_has_plating_signature'])
|
||||
self.assertEqual(res['user_plating_signature'], '')
|
||||
self.env.user.x_fc_signature_image = _TINY_PNG_B64
|
||||
res2 = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
|
||||
self.assertTrue(res2['user_has_plating_signature'])
|
||||
self.assertTrue(
|
||||
res2['user_plating_signature'].startswith('data:image/png;base64,'))
|
||||
|
||||
def test_sign_off_without_signature_and_no_saved_errors(self):
|
||||
self.env.user.x_fc_signature_image = False
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id, 'name': 'Final', 'sequence': 10})
|
||||
res = _rpc(self, '/fp/workspace/sign_off', step_id=step.id)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('signature', res['error'].lower())
|
||||
|
||||
def test_sign_off_with_drawing_persists_signature_and_no_attachment(self):
|
||||
self.env.user.x_fc_signature_image = False
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id, 'name': 'Final', 'sequence': 10})
|
||||
data_uri = 'data:image/png;base64,' + _TINY_PNG_B64
|
||||
# button_finish may fail on this un-started step; we assert the
|
||||
# signature-persist + no-attachment side effects, which happen first.
|
||||
_rpc(self, '/fp/workspace/sign_off',
|
||||
step_id=step.id, signature_data_uri=data_uri)
|
||||
self.env.user.invalidate_recordset(['x_fc_signature_image'])
|
||||
self.assertTrue(
|
||||
self.env.user.x_fc_signature_image,
|
||||
'drawing persisted to the Plating Signature')
|
||||
n = self.env['ir.attachment'].search_count([
|
||||
('res_model', '=', 'fp.job.step'), ('res_id', '=', step.id)])
|
||||
self.assertEqual(n, 0, 'no per-step signature attachment is created')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Static check.** Run:
|
||||
```
|
||||
python -m pyflakes "K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\controllers\workspace_controller.py" "K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\tests\test_workspace_controller.py"
|
||||
```
|
||||
Expected: clean (ignore pre-existing warnings on lines you didn't touch).
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py fusion_plating/fusion_plating_shopfloor/tests/test_workspace_controller.py
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): sign_off reuses+persists Plating Signature; load exposes it"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: New `FpSignatureConfirm` component + manifest registration
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js`
|
||||
- Create: `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml`
|
||||
- Create: `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss`
|
||||
- Modify: `fusion_plating_shopfloor/__manifest__.py` (assets list, after the `signature_pad.*` block ~line 81; version)
|
||||
|
||||
- [ ] **Step 1: Create the JS component.**
|
||||
|
||||
```js
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — SignatureConfirm
|
||||
//
|
||||
// Confirm dialog shown when the operator already has a saved Plating
|
||||
// Signature: previews it + "Sign & Finish" (props.onConfirm) or "Use a
|
||||
// different signature" (props.onRedraw, opens the draw-pad). No drawing here.
|
||||
// =============================================================================
|
||||
import { Component } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class FpSignatureConfirm extends Component {
|
||||
static template = "fusion_plating_shopfloor.SignatureConfirm";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function, // dialog service injects
|
||||
title: { type: String, optional: true },
|
||||
contextLabel: { type: String, optional: true },
|
||||
signatureUrl: { type: String }, // data: URI of saved sig
|
||||
onConfirm: { type: Function }, // () => commit (no drawing)
|
||||
onRedraw: { type: Function }, // () => open draw-pad
|
||||
};
|
||||
|
||||
onConfirm() {
|
||||
this.props.onConfirm();
|
||||
this.props.close();
|
||||
}
|
||||
onRedraw() {
|
||||
this.props.onRedraw();
|
||||
this.props.close();
|
||||
}
|
||||
onCancel() {
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the XML template.**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.SignatureConfirm">
|
||||
<Dialog title="props.title or 'Confirm signature'" size="'md'">
|
||||
<div class="o_fp_sig_confirm">
|
||||
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
|
||||
<t t-esc="props.contextLabel"/>
|
||||
</div>
|
||||
<div class="o_fp_sig_preview">
|
||||
<img t-att-src="props.signatureUrl" alt="Your saved signature"/>
|
||||
</div>
|
||||
<div class="o_fp_sig_hint">Your saved Plating Signature will be applied.</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-link" t-on-click="onRedraw">Use a different signature</button>
|
||||
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" t-on-click="onConfirm">Sign & Finish</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the SCSS.**
|
||||
|
||||
```scss
|
||||
// Confirm-with-preview dialog for shop-floor sign-off. Explicit hex per the
|
||||
// project card-styling rule (don't rely on var(--bs-border-color)).
|
||||
.o_fp_sig_confirm {
|
||||
.o_fp_sig_ctx {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.o_fp_sig_preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 120px;
|
||||
padding: 8px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d8dadd;
|
||||
border-radius: 4px;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 160px;
|
||||
}
|
||||
}
|
||||
.o_fp_sig_hint {
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register assets + bump version** in `__manifest__.py`. Immediately after the three `signature_pad.*` lines (the `.scss`, `.xml`, `.js` block ending ~line 81), insert:
|
||||
|
||||
```python
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/signature_confirm.js',
|
||||
```
|
||||
|
||||
And change `'version': '19.0.37.1.0',` → `'version': '19.0.37.2.0',`.
|
||||
|
||||
- [ ] **Step 5: Static checks.**
|
||||
```
|
||||
python -c "import xml.etree.ElementTree as ET; ET.parse(r'K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\static\src\xml\components\signature_confirm.xml'); print('XML OK')"
|
||||
```
|
||||
Expected: `XML OK`. (Optional JS check: copy `signature_confirm.js` to `$env:TEMP\x.mjs` and `node --check` it if `node` is present.)
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/static/src/js/components/signature_confirm.js fusion_plating/fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss fusion_plating/fusion_plating_shopfloor/__manifest__.py
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): FpSignatureConfirm dialog + asset registration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire confirm-vs-draw into `job_workspace.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_shopfloor/static/src/js/job_workspace.js` (import ~line 27; `static components` ~line 41; `onFinishStep` ~line 364-392)
|
||||
|
||||
- [ ] **Step 1: Import the new component.** After the existing `import { FpSignaturePad } from "./components/signature_pad";` (line 27), add:
|
||||
|
||||
```js
|
||||
import { FpSignatureConfirm } from "./components/signature_confirm";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register it in `static components`.** In the `static components = { ... };` line (~41), add `FpSignatureConfirm` to the set (e.g. right after `FpSignaturePad`):
|
||||
|
||||
```js
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpSignatureConfirm, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `onFinishStep` and add two helpers.** Replace the whole `onFinishStep(step)` method (currently lines ~364-392, the `if (step.requires_signoff) { this.dialog.add(FpSignaturePad, {...}); return; } await this._callFinishStep(step, false);`) with:
|
||||
|
||||
```js
|
||||
async onFinishStep(step) {
|
||||
if (step.requires_signoff) {
|
||||
if (this.state.data.user_has_plating_signature) {
|
||||
// One-tap confirm with preview of the saved Plating Signature.
|
||||
this.dialog.add(FpSignatureConfirm, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
signatureUrl: this.state.data.user_plating_signature,
|
||||
onConfirm: () => this._commitSignOff(step, null), // use saved
|
||||
onRedraw: () => this._openSignaturePad(step), // draw a new one
|
||||
});
|
||||
} else {
|
||||
// First time — draw once; the backend persists it.
|
||||
this._openSignaturePad(step);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Plain finish — routes through /fp/workspace/finish_step which
|
||||
// returns structured errors so we can show the FpFinishBlockDialog.
|
||||
await this._callFinishStep(step, /* bypass */ false);
|
||||
}
|
||||
|
||||
_openSignaturePad(step) {
|
||||
this.dialog.add(FpSignaturePad, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
onSubmit: (dataUri) => this._commitSignOff(step, dataUri),
|
||||
});
|
||||
}
|
||||
|
||||
async _commitSignOff(step, dataUri) {
|
||||
try {
|
||||
const res = await fpRpc("/fp/workspace/sign_off", {
|
||||
step_id: step.id,
|
||||
signature_data_uri: dataUri, // null -> backend uses the saved signature
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step signed off and finished.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`fpRpc`, `this.dialog`, `this.notification`, `this.refresh`, `this._callFinishStep` all already exist in this component — verify the imports/usages are unchanged.)
|
||||
|
||||
- [ ] **Step 4: Static check (optional JS).** Copy `job_workspace.js` to `$env:TEMP\x.mjs` and `node --check $env:TEMP\x.mjs` if `node` is present; otherwise rely on the clone-verify asset compile.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): workspace sign-off confirms saved signature, draws only when absent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify on an entech clone
|
||||
|
||||
**Files:** none (verification only). Mirror the WO-grouping clone-verify recipe.
|
||||
|
||||
- [ ] **Step 1: Clone + upgrade + tests.** On entech: clone `admin` → throwaway UTF-8 DB (`createdb -O odoo -E UTF8 -T template0 --lc-collate=C --lc-ctype=C`, then `pg_dump admin | psql`), stage this branch's `fusion_plating_shopfloor` files into `/mnt/extra-addons/custom/fusion_plating_shopfloor`, then:
|
||||
```
|
||||
odoo -c /etc/odoo/odoo.conf -d <clone> -u fusion_plating_shopfloor --test-enable \
|
||||
--test-tags /fusion_plating_shopfloor:TestWorkspaceSignOff --stop-after-init \
|
||||
--workers=0 --http-port=0 --gevent-port=0 --log-level=test
|
||||
```
|
||||
Expected: exit 0; the 3 new tests pass. (Run the full `/fusion_plating_shopfloor` suite + a baseline diff if any failures appear, to confirm they're pre-existing — same technique as the WO-grouping deploy.)
|
||||
|
||||
- [ ] **Step 2: Asset compile sanity.** Confirm the `-u` compiled the backend bundle without SCSS/XML errors (no `CRITICAL`/`Failed to load` for `signature_confirm`).
|
||||
|
||||
- [ ] **Step 3: Browser smoke (clone or post-deploy).** As a tech with **no** Plating Signature: finish a `requires_signoff` step → draw-pad appears → draw → their `x_fc_signature_image` is set (query DB). Finish another sign-off step → the **confirm-with-preview** dialog appears (no pad) → Sign & Finish works. Render that job's WO Detail → the saved signature shows.
|
||||
|
||||
- [ ] **Step 4: Mark complete.** Suite green + smoke confirmed → ready to deploy `fusion_plating_shopfloor` to entech (standard recipe: backup, stage, `-u`, cache-bust, restart, gated on exit 0).
|
||||
|
||||
---
|
||||
|
||||
## Self-review (by plan author)
|
||||
|
||||
- **Spec coverage:** load payload keys (Task 1) ✓; sign_off optional URI + persist + drop attachment (Task 1) ✓; `FpSignatureConfirm` (Task 2) ✓; workspace confirm-vs-draw + "use a different signature" replaces saved (Task 3) ✓; manifest assets + version (Task 2) ✓; tablet-only scope, no model/migration ✓.
|
||||
- **Placeholder scan:** no TBD/TODO; every code step has complete code; `<clone>` in Task 4 is an explicit env parameter.
|
||||
- **Type/name consistency:** `signature_data_uri` (optional, default None) consistent across controller + JS; payload keys `user_has_plating_signature` / `user_plating_signature` consistent between controller (Task 1), workspace `this.state.data.*` (Task 3); `FpSignatureConfirm` props (`signatureUrl`, `onConfirm`, `onRedraw`) consistent between the component (Task 2) and its caller (Task 3); `_commitSignOff` / `_openSignaturePad` defined and used in Task 3.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Box-Level Tracking + Job Sticker Redesign — Design Spec
|
||||
|
||||
Date: 2026-06-03
|
||||
Status: Approved (brainstormed with client), implementation in progress.
|
||||
|
||||
## Summary
|
||||
|
||||
Two coupled deliverables:
|
||||
|
||||
1. **Job sticker redesign** (thermal-label-friendly, 6×4 in / 152×102 mm):
|
||||
- **Internal Job Sticker → Layout A** (stacked: identity band + full-width
|
||||
instructions), printed **one per job**.
|
||||
- **External Job Sticker → Layout B** (left identity rail + tall instructions
|
||||
column), printed **one per box**, carrying the **box identity** (BOX n/N)
|
||||
and a **per-box QR**. Shows the **factory logo** (`env.company.logo`).
|
||||
2. **Box-level tracking**: a new `fp.box` registry, one record per received box,
|
||||
auto-created at receiving, with a status workflow and per-box scannable QR.
|
||||
|
||||
## Decisions (locked with client)
|
||||
|
||||
| Q | Decision |
|
||||
|---|---|
|
||||
| Label size | Keep 6×4 in (152×102 mm). |
|
||||
| Redesign goals | Readability/scan-speed + thermal print quality (no grey fills — solid-black bands + knockout white text; thick rules; bold sans). |
|
||||
| Masking on label | **MASK badge** (on/off flag) when `sale.order.line.x_fc_masking_enabled` is true. No detail text. |
|
||||
| Baking on label | **BAKE block** showing `sale.order.line.x_fc_bake_instructions` text, only when present. Also a BAKE flag for at-a-glance. |
|
||||
| Notes source | Internal = `x_fc_internal_description`; External = SO line `name` (customer-facing). |
|
||||
| Long notes | Notes-dominant zone, **length-tiered font shrink** to keep to **one label**, clip with "…see traveller" only in the extreme. |
|
||||
| Factory logo | On **External only** (header), from `env.company.logo` → `logo_web` → company partner image. Internal stays clean. Thermal caveat: prefer a mono/high-contrast logo. |
|
||||
| Box tracking depth | **Box registry** — per-box record, status, scannable QR. (Not box-contents.) |
|
||||
| Internal copies | **One per job.** |
|
||||
| External copies | **One per box.** |
|
||||
| Box QR | **Per-box** — encodes `/fp/box/<id>`. |
|
||||
|
||||
## Label layouts (approved mockups)
|
||||
|
||||
Both labels: outer 0.9 mm border, `overflow:hidden` single-page guard, dynamic
|
||||
blocks render only when their field has content.
|
||||
|
||||
**Layout A (Internal, per job):** full-width stacked rows —
|
||||
`[logo | WO# band + INTERNAL tag | QR]` → `Part# + MASK/BAKE flags` →
|
||||
one-line field strip `Customer · PO · Qty · Due · Thk` → `BAKE` block →
|
||||
`NOTES` (full width, `x_fc_internal_description`, length-tiered, bottom padding).
|
||||
|
||||
**Layout B (External, per box):** absolute two-column —
|
||||
- Left rail (50 mm): `logo` → black band `WORK ORDER <wo> | BOX n / N` →
|
||||
`MASK/BAKE` flags → per-box QR → `Part#` → `Customer` → `PO/Qty` → `Due/Thk`.
|
||||
- Right column: `BAKE` block → `NOTES` (customer description, length-tiered).
|
||||
- Full-height divider (rail `border-right`). CUSTOMER copy.
|
||||
|
||||
Reference mockups (Chrome-rendered, true 6×4):
|
||||
`~/Downloads/fusion_sticker_concepts/Sticker-A-Internal-LongNotes.*`,
|
||||
`Sticker-B-External.*`. Final proof renders through entech wkhtmltopdf.
|
||||
|
||||
## `fp.box` model (fusion_plating_receiving)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | Char | Sequence, e.g. `BOX/<wo-or-recv>/01`. |
|
||||
| `box_number` | Integer | n (1..N). |
|
||||
| `box_count` | Integer | N (related/snapshot of receiving `box_count_in`). |
|
||||
| `receiving_id` | M2O `fp.receiving` | Origin. ondelete cascade. |
|
||||
| `sale_order_id` | M2O `sale.order` | Related from receiving. |
|
||||
| `job_id` | M2O `fp.job` | Resolved (single-job SO = that job; multi-job = first/SO-level, see edge cases). |
|
||||
| `partner_id` | M2O `res.partner` | Related (customer). |
|
||||
| `state` | Selection | `received → racked → in_process → packed → shipped` (+ `lost`/`cancelled`). |
|
||||
| `qr` | Binary/compute | Encodes `<base_url>/fp/box/<id>`. |
|
||||
| `location_note` | Char | Optional free text "where is it now". |
|
||||
| `scan_event_ids` | (phase 2) | Per-scan log — deferred. |
|
||||
|
||||
Constraints: `(receiving_id, box_number)` unique. Append-only-ish; state advances.
|
||||
|
||||
## Auto-create at receiving
|
||||
|
||||
When `fp.receiving.box_count_in = N` is set and the receiving is confirmed
|
||||
(state hook — reuse the existing box-count chatter point at
|
||||
`fp_receiving.py:~1191`), create/sync N `fp.box` rows (1..N), linked to the
|
||||
receiving + resolved job. **Idempotent**: changing N adds/removes trailing rows
|
||||
(never renumbers existing tracked boxes). Manager can regenerate.
|
||||
|
||||
## Scanning
|
||||
|
||||
- Controller route `/fp/box/<int:box_id>` → resolves the box, shows its job /
|
||||
status, allows advancing state (received→…→shipped). Tie into the existing
|
||||
shopfloor scan wedge (`request.env.user` attribution — no `tablet_tech_id`).
|
||||
- **Reconciliation**: helper flags a receiving/job whose boxes haven't all
|
||||
reached `shipped` (so none are lost — matches the "ship back in the same
|
||||
boxes" Sub-8 rule).
|
||||
|
||||
## Label binding
|
||||
|
||||
- **External job sticker** (`fusion_plating_jobs.report_fp_job_sticker_template`):
|
||||
iterate the job's `fp.box` records → **one label per box** (Layout B), each
|
||||
with its `box_number/box_count` + per-box QR (`/fp/box/<id>`). Replaces the
|
||||
current `range(box_count_in)` loop in `report_fp_wo_sticker_inner`. When a job
|
||||
has no `fp.box` rows yet, fall back to a single label (BOX 1/1).
|
||||
- **Internal job sticker** (`report_fp_job_sticker_internal_template`): **one per
|
||||
job** (Layout A), job QR (`/fp/job/<id>`), no box loop.
|
||||
- Shared inner keeps the 100-label hard safety cap (defense-in-depth from the
|
||||
WO-30072 OOM fix).
|
||||
|
||||
## UI
|
||||
|
||||
- Boxes list + kanban (group by `state`) under **Operations**; form with state
|
||||
buttons + scan QR.
|
||||
- Smart buttons: box count on `fp.receiving` and `fp.job` forms.
|
||||
|
||||
## Module placement
|
||||
|
||||
- Model + auto-create + views/menu/ACL → `fusion_plating_receiving`.
|
||||
- Scan controller → `fusion_plating_receiving` (or shopfloor).
|
||||
- Label templates → `fusion_plating_jobs` (job stickers) + shared inner in
|
||||
`fusion_plating_reports`.
|
||||
|
||||
## Edge cases / open
|
||||
|
||||
- **Multi-job SO** (one SO line → multiple jobs via serial/thickness grouping):
|
||||
boxes are physical (per shipment/receiving). MVP links a box to the SO's
|
||||
primary job; the external sticker prints the SO's boxes. Revisit if a real
|
||||
multi-job-per-box case appears.
|
||||
- **Box ↔ part for multi-part SO**: out of MVP (registry, not contents).
|
||||
- Per-box qty/contents = future "registry + contents" upgrade.
|
||||
|
||||
## Deploy / verify
|
||||
|
||||
entech (LXC 111 / pve-worker5), `-u fusion_plating_receiving fusion_plating_jobs
|
||||
fusion_plating_reports` with the revert-on-failure guard. Verify: render both
|
||||
stickers for a real job through wkhtmltopdf; confirm auto-create on a test
|
||||
receiving; scan a box id.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Multi-Rack Splitting + Work-Order Grouping at Racking — Design
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Status:** Approved (design sign-off 2026-06-03)
|
||||
**Modules touched:** `fusion_plating` (core: rack-load models), `fusion_plating_jobs` (movement / partial-order integration), `fusion_plating_shopfloor` (UI surfaces + controllers), `fusion_plating_reports` (rack travel ticket reuse)
|
||||
|
||||
## 1. Problem / Goal
|
||||
|
||||
At the **Racking** step, operators load a job's parts onto physical racks before plating. Today a step links to exactly **one** rack (`fp.job.step.rack_id`, single Many2one) and there is **no model for partial parts-per-rack** or **multiple work orders sharing a rack**. Operators need to:
|
||||
|
||||
1. **Split a job across multiple racks.** Default: all parts on one rack. An **"+ Add Rack"** button divides the quantity equally (100 → 50/50 → 34/33/33 → 25×4…). The operator can then **manually override** any individual rack's quantity.
|
||||
2. **Move racks independently** through the rest of the line (Plating → Baking → De-Racking) — partial-order flow, but rack-aware. The operator chooses which rack(s) advance.
|
||||
3. **Group multiple work orders on one rack** when they run the **identical recipe + spec** (any customer), for line efficiency — e.g. WO-A (20 ENP parts) + WO-B (10 ENP parts) on one rack, processed together, then separated at De-Racking.
|
||||
|
||||
## 2. Locked Decisions (from brainstorm 2026-06-03)
|
||||
|
||||
| # | Decision |
|
||||
|---|----------|
|
||||
| D1 | **Rack movement = independent, operator's choice.** Each rack is its own trackable unit; it can move ahead on its own, or the operator can move several at once. |
|
||||
| D2 | **Grouping eligibility = identical process + spec.** Only WOs with the same resolved recipe AND same coating spec / thickness target may share a rack. Different customers are allowed. Mismatched recipe/spec is **blocked**. |
|
||||
| D3 | **Two UI surfaces.** (a) A per-WO **Racking panel** on the Job Workspace (the split case). (b) A dedicated **Racking Station** shop-floor screen listing all WOs at Racking, with split controls *and* cross-WO grouping. Both drive the same model + endpoints. |
|
||||
| D4 | **Division remainder** goes to the first rack(s): `base = total // N`, the first `total % N` racks get `base + 1`. Total always equals the parts available. |
|
||||
| D5 | **Capacity = soft warning.** Each rack shows `assigned / capacity`; over-capacity is an amber warning, never a hard block. |
|
||||
| D6 | **Plant Kanban = one card per job** with a small **rack rollup** ("3 racks · 1 Baking, 2 Plating"). The job card sits in the column of its **least-advanced** rack-load (a WO isn't "done" until every rack clears). Per-rack detail lives on the Racking screen / a card drill-down — NOT as separate board cards. |
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### 3.1 `fp.rack.load` (new, in `fusion_plating`)
|
||||
"Parts loaded on one physical rack." First-class, moves through the workflow independently.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | Char | Sequence `RACKLOAD/YYYY/NNNN` |
|
||||
| `rack_id` | Many2one `fusion.plating.rack` | The physical rack |
|
||||
| `line_ids` | One2many `fp.rack.load.line` (inverse `load_id`) | Per-WO allocation (1 line = single WO; 2+ = grouped) |
|
||||
| `qty_total` | Integer (compute, stored) | `sum(line_ids.qty)` |
|
||||
| `recipe_id` | Many2one (recipe ref) | The shared recipe (all lines must match) — for grouping eligibility + display |
|
||||
| `spec_key` | Char (compute, stored) | Normalised spec/thickness signature used to enforce D2 grouping |
|
||||
| `current_step_id` | Many2one `fp.job.step` | The step the rack-load is parked at (drives independent position) |
|
||||
| `current_area_kind` | Char (compute, stored) | From `current_step_id.area_kind` — for the Plant Kanban column |
|
||||
| `state` | Selection | `loading` → `loaded` → `running` → `unracked` (→ `cancelled`) |
|
||||
| `tag_ids` | Many2many `fp.rack.tag` | Reuse existing rack tags (Rush / Hold for QC) |
|
||||
| `company_id` | Many2one | Standard |
|
||||
| chatter | mail.thread | Audit |
|
||||
|
||||
Constraints: a rack-load's `line_ids` must all share `recipe_id` + `spec_key` (D2); `qty_total` must be ≥ 1; `rack_id` unique among non-unracked loads (a physical rack holds one active load at a time).
|
||||
|
||||
### 3.2 `fp.rack.load.line` (new, in `fusion_plating`)
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `load_id` | Many2one `fp.rack.load`, required, ondelete cascade | |
|
||||
| `job_id` | Many2one `fp.job`, required | The work order whose parts are on this rack |
|
||||
| `qty` | Integer, required | Parts of this job on this rack |
|
||||
| `part_catalog_id` | Many2one (related from job) | Display |
|
||||
| `recipe_id` / `spec_key` | related/compute from job | Used to enforce D2 |
|
||||
|
||||
### 3.3 Job ↔ rack-load relationships (on `fp.job`, in `fusion_plating_jobs`)
|
||||
- `rack_load_line_ids` (One2many to `fp.rack.load.line`) — all loads carrying this job's parts.
|
||||
- `qty_racked` (compute) = sum of this job's load-line qtys — how many of the job's parts are on racks.
|
||||
- `qty_unracked` (compute) = `qty_at_racking_step − qty_racked` — parts not yet assigned to a rack (the "Unassigned" counter).
|
||||
|
||||
## 4. Division Math (the "+ Add Rack" behaviour)
|
||||
|
||||
- Default state: **1 rack-load, line.qty = full racking quantity**.
|
||||
- **+ Add Rack** → create one more rack-load and **re-divide equally** across all current loads (D4): `base = total // N`; first `total % N` loads get `base + 1`. This overwrites all line qtys (the simple behaviour: "add 4th rack → divide by 4").
|
||||
- **Divide Equally** button → same as above without adding a rack (re-balance current N).
|
||||
- **Manual qty edit** on a rack → updates that load's line qty; the **Unassigned: N** counter recomputes (`total − Σ assigned`). Manual edits persist until the next *Add Rack* / *Divide Equally*. Sum may not exceed `total` (validation). Sum < total is allowed (operator may rack in waves) and shown as Unassigned.
|
||||
- **Remove Rack** → only when its load hasn't moved past Racking; its qty returns to Unassigned.
|
||||
|
||||
## 5. Independent Movement + Partial-Order Integration
|
||||
|
||||
- Movement reuses the existing **move log** `fp.job.step.move`. When a rack-load advances from step A → B, create **one move row per line** (per job): `from_step_id`, `to_step_id`, `qty_moved = line.qty`, `rack_id = load.rack_id`, `transfer_type = 'step'`. This keeps the existing `qty_at_step` partial-order compute correct and rack-aware.
|
||||
- The rack-load's `current_step_id` is set to the destination on commit (explicit position for the independent-movement UI), and `state` flips `loaded → running`.
|
||||
- The operator can move **one** load or **select several** to move together (D1). Reuse / extend the existing **Move Rack** tablet dialog (`move_rack_dialog.js` + `/fp/tablet/move_rack/*`) so a rack-load moves as a unit; the multi-select batch move is a thin wrapper.
|
||||
- **De-Racking** = unrack. When a rack-load reaches the De-Racking step and is unracked: set `state = unracked`, free the physical rack (`rack.racking_state = 'empty'`), and each line's `qty` returns to **its own** job's downstream flow (inspection → cert → shipping). Grouped WOs separate cleanly here — each job continues with its own parts/qty.
|
||||
|
||||
## 6. Work-Order Grouping (D2)
|
||||
|
||||
- On the Racking Station screen, eligible WOs at Racking (same `recipe_id` + `spec_key`, any customer) can be **pulled onto a shared rack-load** → adds a `fp.rack.load.line` for the second job.
|
||||
- Eligibility is enforced server-side: adding a line whose job's recipe/spec differs from the load's is rejected with a clear message.
|
||||
- A grouped rack-load moves as one unit (§5); at De-Racking each line returns to its job (§5).
|
||||
|
||||
## 7. UI Surfaces
|
||||
|
||||
### 7.1 Job Workspace → Racking panel (per-WO) — `fusion_plating_shopfloor`
|
||||
- Appears on the Job Workspace when the WO is at the Racking step (mirrors the existing Receiving card pattern).
|
||||
- Shows: total parts, **Unassigned: N**, a list of rack-loads each with `[rack picker] [qty input] [assigned/capacity bar] [remove]`, **+ Add Rack** and **Divide Equally** buttons.
|
||||
- Split / qty-edit only (single WO). Grouping is not done here.
|
||||
|
||||
### 7.2 Racking Station screen (new) — `fusion_plating_shopfloor`
|
||||
- New OWL client action + menu under Shop Floor.
|
||||
- Lists all WOs currently at the Racking step (grouped by recipe/spec for grouping visibility).
|
||||
- Per-WO split controls (same as 7.1) **plus** "Combine onto rack" to pull an eligible WO onto another's rack-load.
|
||||
- Shows rack capacity bars + over-capacity warnings.
|
||||
|
||||
### 7.3 Shared controller endpoints — `fusion_plating_shopfloor/controllers`
|
||||
- `/fp/racking/load` (GET context for a WO or the station)
|
||||
- `/fp/racking/add_rack` / `divide_equally` / `set_qty` / `remove_rack`
|
||||
- `/fp/racking/assign_rack` (pick/scan the physical rack for a load — reuse `/rack/list_empty` + `/rack/scan_qr`)
|
||||
- `/fp/racking/group` (add an eligible WO's line to a load) / `ungroup`
|
||||
- `/fp/racking/move` (advance one or more rack-loads to the next step — wraps the move-log writes)
|
||||
All run as `request.env.user` (the technician) reusing existing rack/move ACLs.
|
||||
|
||||
## 8. Plant Kanban Representation (D6)
|
||||
- One card per job. Card column = area of the job's **least-advanced** rack-load (`min` over `rack_load_line_ids.load_id.current_area_kind` by column sequence), falling back to today's `active_step_id.area_kind` when the job has no rack-loads.
|
||||
- Card shows a compact **rack rollup** chip ("3 racks · 1 Baking, 2 Plating"). Tapping the chip / card opens a per-rack drill-down (or routes to the Racking screen).
|
||||
- No new board columns; no per-rack board cards.
|
||||
|
||||
## 9. Phasing (single spec, built in order)
|
||||
1. **Phase 1 — Split + independent movement.** `fp.rack.load` + `fp.rack.load.line`, division math, move-log integration, De-Racking unrack, Job Workspace Racking panel. Single-WO only (one line per load).
|
||||
2. **Phase 2 — WO grouping + Racking Station screen.** Multi-line loads, eligibility enforcement, the dedicated cross-WO surface.
|
||||
3. **Phase 3 — Plant Kanban rollup + drill-down.**
|
||||
|
||||
## 10. Integration Points / Reuse
|
||||
- `fusion.plating.rack` (capacity, racking_state, tags) — reused; rack-load references it.
|
||||
- `fp.job.step.move` / `qty_at_step` partial-order compute — reused, now rack-aware.
|
||||
- `move_rack_dialog.js` + `/fp/tablet/move_rack/*` + `/rack/list_empty` + `/rack/scan_qr` — reused/extended.
|
||||
- Rack Travel Ticket PDF (`report_fp_rack_travel`) — reused (print a load's ticket).
|
||||
- `_fp_is_racking_step` / racking inspection gate — unchanged; rack-loads are created at the racking step.
|
||||
|
||||
## 11. Edge Cases / Rules
|
||||
- Sum of load qtys may be **< total** (rack in waves); the remainder shows as Unassigned and can be racked later.
|
||||
- A load can't be removed/edited once it has moved past Racking.
|
||||
- One physical rack = one active (non-unracked) load at a time.
|
||||
- Over-capacity = soft amber warning only.
|
||||
- Cancelling a job cascades its load lines; a load with no remaining lines is cancelled.
|
||||
- Migration: existing single `fp.job.step.rack_id` assignments are left as-is (legacy); new flow uses rack-loads. No destructive backfill.
|
||||
|
||||
## 12. Out of Scope (this spec)
|
||||
- Auto-suggesting which WOs to group (operator-driven only).
|
||||
- Rack capacity *planning*/optimisation.
|
||||
- Changing the De-Racking inspection model.
|
||||
- Reworking the legacy `rack_id`-on-step flow (kept for back-compat).
|
||||
@@ -0,0 +1,192 @@
|
||||
# Shop-Floor Sign-Off: Reuse the Saved Plating Signature
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Module(s):** `fusion_plating_shopfloor` (frontend + controller), reads `res.users.x_fc_signature_image` (defined in `fusion_plating_jobs`)
|
||||
**Author:** Gurpreet (Nexa Systems Inc.)
|
||||
**Status:** Draft — pending user review of this spec
|
||||
|
||||
## Summary
|
||||
|
||||
On the shop-floor Job Workspace, finishing any recipe step with
|
||||
`requires_signoff=True` pops a draw-pad and makes the operator **draw a
|
||||
signature from scratch every time**. Worse, that per-step drawing is
|
||||
saved as an `ir.attachment` on the step and then **never used** — the WO
|
||||
Detail / CoC reports render the signer's **Plating Signature**
|
||||
(`res.users.x_fc_signature_image`, per CLAUDE.md rule 14b), not the step
|
||||
attachment.
|
||||
|
||||
This change makes sign-off reuse the operator's saved **Plating
|
||||
Signature**: if they have one, finishing is a one-tap confirm (preview +
|
||||
"Sign & Finish"); if they don't, they draw once and it is **persisted to
|
||||
their Plating Signature**, so every later sign-off — and every report —
|
||||
uses it without redrawing.
|
||||
|
||||
## Current behaviour (the bug)
|
||||
|
||||
- `onFinishStep` ([job_workspace.js:364](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js)) — when `step.requires_signoff`, always opens `FpSignaturePad`; on submit POSTs the drawing to `/fp/workspace/sign_off`.
|
||||
- `/fp/workspace/sign_off` ([workspace_controller.py:451](../../../fusion_plating_shopfloor/controllers/workspace_controller.py)) — requires a non-empty `signature_data_uri`, creates a per-step `ir.attachment` from it, then calls `step.button_finish()` (which sets `signoff_user_id` via `_fp_autosign_if_required`).
|
||||
- Reports read `signer_user.x_fc_signature_image`, **not** the step attachment → the drawing is wasted.
|
||||
- `x_fc_signature_image` = `fields.Binary(string='Plating Signature', attachment=True)` on `res.users` (defined in `fusion_plating_jobs/models/res_users.py`), already in `SELF_READABLE_FIELDS` **and** `SELF_WRITEABLE_FIELDS` (fusion_plating/models/res_users.py) — so a tablet tech can read and write **their own** signature with no sudo.
|
||||
|
||||
## Locked decisions (from brainstorming, 2026-06-04)
|
||||
|
||||
| Q | Decision |
|
||||
|---|----------|
|
||||
| Finish UX when the user HAS a saved signature | **Quick confirm with preview** — small dialog showing their saved signature + "Sign & Finish", plus a "Use a different signature" link. One tap, no drawing. |
|
||||
| Finish UX when the user has NO saved signature | Existing draw-pad → on submit, **persist the drawing to their Plating Signature** + finish. |
|
||||
| "Use a different signature" | Opens the draw-pad; the new drawing **replaces** their saved Plating Signature (it is their signature) and signs this step. |
|
||||
| Per-step signature `ir.attachment` | **Dropped** — redundant (reports never read it). Audit of *who signed when* stays on `signoff_user_id` + the finish timestamp. |
|
||||
| Scope | **Tablet Job Workspace only.** The backend job-form `action_signoff` already works off `x_fc_signature_image` implicitly (no draw UI) — unchanged. |
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
**Goals**
|
||||
- A user with a saved Plating Signature never redraws — one-tap confirm.
|
||||
- A user without one draws exactly once; it persists to their Plating Signature.
|
||||
- The signature shown on certs/WO reports is the same saved Plating Signature (already true; this guarantees it exists).
|
||||
|
||||
**Non-goals**
|
||||
- Changing the backend `action_signoff` / job-form flow.
|
||||
- Per-signoff historical signature snapshots (reports already read the *live* `x_fc_signature_image`; not changing that).
|
||||
- Touching the signoff gate logic (`requires_signoff`, `_fp_autosign_if_required`, `_fp_check_signoff_complete`) — unchanged.
|
||||
- QC-checklist or any non-workspace signature surface (none use `FpSignaturePad`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Workspace load payload — expose the saved signature
|
||||
|
||||
In the `/fp/workspace/load` payload builder (`workspace_controller.py`),
|
||||
add two keys derived from the current user (`request.env.user`, already
|
||||
the per-tech session):
|
||||
|
||||
```python
|
||||
user = request.env.user
|
||||
sig = user.x_fc_signature_image # base64 or False (SELF_READABLE)
|
||||
payload['user_has_plating_signature'] = bool(sig)
|
||||
payload['user_plating_signature'] = (
|
||||
('data:image/png;base64,%s' % sig.decode()) if sig else ''
|
||||
)
|
||||
```
|
||||
|
||||
(`x_fc_signature_image` is a small PNG; one data URI per load is fine. If
|
||||
it ever grows, switch to a `/web/image/res.users/<uid>/x_fc_signature_image`
|
||||
URL — deferred.)
|
||||
|
||||
### 2. Frontend — confirm-vs-draw in `onFinishStep`
|
||||
|
||||
`job_workspace.js`, `onFinishStep(step)` — replace the unconditional
|
||||
`FpSignaturePad` branch with:
|
||||
|
||||
```js
|
||||
if (step.requires_signoff) {
|
||||
if (this.state.data.user_has_plating_signature) {
|
||||
this.dialog.add(FpSignatureConfirm, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
signatureUrl: this.state.data.user_plating_signature,
|
||||
onConfirm: () => this._commitSignOff(step, null), // no drawing -> use saved
|
||||
onRedraw: () => this._openSignaturePad(step), // draw -> replaces saved
|
||||
});
|
||||
} else {
|
||||
this._openSignaturePad(step); // first time -> draw + persist
|
||||
}
|
||||
return;
|
||||
}
|
||||
await this._callFinishStep(step, false); // plain finish (unchanged)
|
||||
```
|
||||
|
||||
New helpers:
|
||||
- `_openSignaturePad(step)` — opens the existing `FpSignaturePad`; its `onSubmit(dataUri)` calls `this._commitSignOff(step, dataUri)`.
|
||||
- `_commitSignOff(step, dataUri)` — POSTs `{ step_id, signature_data_uri: dataUri /* may be null */ }` to `/fp/workspace/sign_off`, handles ok/error notifications + `refresh()` (the existing logic, factored out of the current inline `onSubmit`).
|
||||
|
||||
### 3. New OWL component — `FpSignatureConfirm`
|
||||
|
||||
`fusion_plating_shopfloor/static/src/js/components/signature_confirm.js`
|
||||
(+ `signature_confirm.xml`, reuse `_signature_pad.scss` tokens or add a
|
||||
small `_signature_confirm.scss`). A `Dialog` showing:
|
||||
- the saved signature image (`<img t-att-src="props.signatureUrl"/>`),
|
||||
- the context label,
|
||||
- **Sign & Finish** → `props.onConfirm(); props.close();`
|
||||
- **Use a different signature** → `props.onRedraw(); props.close();`
|
||||
- **Cancel** → `props.close();`
|
||||
|
||||
Props: `close, title?, contextLabel?, signatureUrl, onConfirm, onRedraw`.
|
||||
Mirrors `FpSignaturePad`'s shape. Register it in `JobWorkspace.components`
|
||||
and the manifest assets.
|
||||
|
||||
### 4. Backend — `/fp/workspace/sign_off` persists, drops the attachment
|
||||
|
||||
`workspace_controller.py`, `sign_off(self, step_id, signature_data_uri=None)`:
|
||||
|
||||
```python
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': f'Step {step_id} not found'}
|
||||
|
||||
sig = (signature_data_uri or '').strip()
|
||||
user = env.user
|
||||
if sig:
|
||||
# A drawing was supplied (first-time, or "use a different signature").
|
||||
if ',' in sig and sig.startswith('data:'):
|
||||
sig = sig.split(',', 1)[1]
|
||||
try:
|
||||
user.write({'x_fc_signature_image': sig}) # SELF_WRITEABLE; own record
|
||||
except Exception:
|
||||
_logger.exception("sign_off: persisting Plating Signature failed for uid %s", env.uid)
|
||||
return {'ok': False, 'error': 'Failed to save your signature.'}
|
||||
elif not user.x_fc_signature_image:
|
||||
# No drawing AND no saved signature — nothing to sign with.
|
||||
return {'ok': False, 'error': 'A signature is required. Draw one to continue.'}
|
||||
|
||||
try:
|
||||
step.button_finish() # sets signoff_user_id + gates
|
||||
except Exception as exc:
|
||||
_logger.exception("sign_off: button_finish failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
return {'ok': True, 'step_id': step.id, 'state': step.state}
|
||||
```
|
||||
|
||||
- `signature_data_uri` is now **optional** (defaults `None`).
|
||||
- No `ir.attachment` is created (the dropped per-step artifact).
|
||||
- The signature persists to the user's own `x_fc_signature_image` (direct write — the field is in `SELF_WRITEABLE_FIELDS`).
|
||||
|
||||
## Files touched
|
||||
|
||||
| # | File | Change |
|
||||
|---|------|--------|
|
||||
| 1 | `fusion_plating_shopfloor/controllers/workspace_controller.py` | `sign_off`: optional `signature_data_uri`, persist to `x_fc_signature_image`, drop attachment; add `user_has_plating_signature` + `user_plating_signature` to the load payload. |
|
||||
| 2 | `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` | NEW confirm dialog. |
|
||||
| 3 | `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` | NEW template. |
|
||||
| 4 | `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` | NEW (small). |
|
||||
| 5 | `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `onFinishStep` branch; `_openSignaturePad` + `_commitSignOff` helpers; register `FpSignatureConfirm`. |
|
||||
| 6 | `fusion_plating_shopfloor/__manifest__.py` | add the 3 new asset files + version bump. |
|
||||
|
||||
No model, view, ACL, or migration changes. `res.users.x_fc_signature_image` already exists with the right SELF_* access.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|------|-----------|
|
||||
| Has saved sig → "Sign & Finish" | No drawing sent; `button_finish()` only; report uses saved sig. |
|
||||
| No saved sig → draw | Drawing persists to `x_fc_signature_image`; future steps are one-tap. |
|
||||
| Has saved sig → "Use a different signature" → draw | New drawing **replaces** saved sig + signs. |
|
||||
| Empty draw | `FpSignaturePad.onSubmit` already no-ops without ink; backend also rejects empty+no-saved. |
|
||||
| `button_finish` raises a gate error (required inputs, predecessor, etc.) | Returned as `{ok:false, error}` and shown as a notification — the signature has already persisted (harmless; it's their signature either way). |
|
||||
| Manager/Owner with no saved sig | Same flow — draws once, persists. |
|
||||
|
||||
## Testing
|
||||
|
||||
`fusion_plating_shopfloor` can't install on local Community; verify on an
|
||||
entech clone (`-u` + odoo-shell), like the WO-grouping deploy.
|
||||
|
||||
- **Unit (controller logic, runnable where the module installs):** `sign_off` with a data URI writes `env.user.x_fc_signature_image` and finishes; `sign_off` with no URI + an existing saved sig finishes without writing; `sign_off` with no URI + no saved sig returns the "signature required" error; no `ir.attachment` is created in any path.
|
||||
- **Payload:** `/fp/workspace/load` returns `user_has_plating_signature=False` + empty `user_plating_signature` for a user with no sig, and `True` + a `data:image/png;base64,…` URI once set.
|
||||
- **Live smoke (entech clone):** a tech with no Plating Signature draws on a sign-off step → their `x_fc_signature_image` is populated; the next sign-off shows the confirm-preview (no pad); the WO Detail report renders the saved signature.
|
||||
|
||||
## Static-check note
|
||||
|
||||
`node --check` rejects ESM `import` on a `.js`; copy the OWL files to
|
||||
`/tmp/x.mjs` for a syntax check, and lxml/ET-parse the `.xml` template
|
||||
(per the project's static-check conventions).
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.22.2.0',
|
||||
'version': '19.0.23.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -93,6 +93,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'data/fp_sequence_data.xml',
|
||||
'data/fp_job_sequences.xml',
|
||||
'data/fp_numbering_sequences.xml',
|
||||
'data/fp_rack_load_sequence.xml',
|
||||
'data/fp_process_category_data.xml',
|
||||
# fp_menu.xml MUST load early — defines menu_fp_root, menu_fp_config,
|
||||
# menu_fp_compliance_hub, plus the 7 Phase-2 Configuration sub-folder
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="seq_fp_rack_load" model="ir.sequence">
|
||||
<field name="name">Rack Load</field>
|
||||
<field name="code">fp.rack.load</field>
|
||||
<field name="prefix">RACKLOAD/%(year)s/</field>
|
||||
<field name="padding">4</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -55,3 +55,4 @@ from . import fp_landing
|
||||
# imports the predicate chain + xmlid maps from the former).
|
||||
from . import fp_role_constants
|
||||
from . import fp_migration
|
||||
from . import fp_rack_load
|
||||
|
||||
94
fusion_plating/fusion_plating/models/fp_rack_load.py
Normal file
94
fusion_plating/fusion_plating/models/fp_rack_load.py
Normal file
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Multi-rack splitting + WO grouping at Racking — Phase 1 core models.
|
||||
# Spec: docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md
|
||||
# Plan: docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md
|
||||
#
|
||||
# This file (core module) deliberately depends only on CORE fields. The
|
||||
# racking-step-aware division ops, the fp.job rollups, movement, and the
|
||||
# current_area_kind compute live in fusion_plating_jobs/models/fp_job_rack.py
|
||||
# (that module owns fp.job.step.area_kind, fp.job.part_catalog_id, and
|
||||
# _fp_is_racking_step).
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class FpRackLoad(models.Model):
|
||||
_name = 'fp.rack.load'
|
||||
_description = 'Rack Load (parts on one physical rack)'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference', required=True, copy=False, index=True,
|
||||
default=lambda self: _('New'))
|
||||
rack_id = fields.Many2one(
|
||||
'fusion.plating.rack', string='Rack', tracking=True)
|
||||
line_ids = fields.One2many(
|
||||
'fp.rack.load.line', 'load_id', string='Work Orders')
|
||||
qty_total = fields.Integer(
|
||||
string='Total Parts', compute='_compute_qty_total', store=True)
|
||||
current_step_id = fields.Many2one(
|
||||
'fp.job.step', string='Current Step', tracking=True)
|
||||
state = fields.Selection([
|
||||
('loading', 'Loading'),
|
||||
('loaded', 'Loaded'),
|
||||
('running', 'Running'),
|
||||
('unracked', 'Unracked'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='State', default='loading', required=True, tracking=True)
|
||||
tag_ids = fields.Many2many('fp.rack.tag', string='Tags')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company)
|
||||
|
||||
@api.depends('line_ids.qty')
|
||||
def _compute_qty_total(self):
|
||||
for load in self:
|
||||
load.qty_total = sum(load.line_ids.mapped('qty'))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = (
|
||||
self.env['ir.sequence'].next_by_code('fp.rack.load')
|
||||
or _('New'))
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pure division math (no DB) — verifiable in isolation.
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _fp_equal_split(self, total, n):
|
||||
"""Split ``total`` parts across ``n`` racks as evenly as possible.
|
||||
|
||||
Remainder goes to the FIRST racks (spec D4): 100/3 -> [34, 33, 33].
|
||||
Returns a list of n ints summing to total. n < 1 -> [].
|
||||
"""
|
||||
n = int(n)
|
||||
if n < 1:
|
||||
return []
|
||||
base, rem = divmod(int(total), n)
|
||||
return [base + 1 if i < rem else base for i in range(n)]
|
||||
|
||||
_qty_total_non_negative = models.Constraint(
|
||||
'CHECK (qty_total >= 0)',
|
||||
'Rack load total quantity cannot be negative.')
|
||||
|
||||
|
||||
class FpRackLoadLine(models.Model):
|
||||
_name = 'fp.rack.load.line'
|
||||
_description = 'Rack Load Line (one work order on a rack)'
|
||||
|
||||
load_id = fields.Many2one(
|
||||
'fp.rack.load', string='Rack Load', required=True, ondelete='cascade')
|
||||
job_id = fields.Many2one('fp.job', string='Work Order', required=True)
|
||||
qty = fields.Integer(string='Parts', required=True, default=0)
|
||||
|
||||
_qty_non_negative = models.Constraint(
|
||||
'CHECK (qty >= 0)',
|
||||
'Rack load line quantity cannot be negative.')
|
||||
@@ -40,6 +40,14 @@
|
||||
<field name="privilege_id"
|
||||
ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
<field name="sequence">90</field>
|
||||
<!-- 2026-06-02: office_user also grants "Contact Creation"
|
||||
(base.group_partner_manager) so back-office staff + managers
|
||||
can create contacts/companies. office_user is implied by every
|
||||
fp role ABOVE Technician (Sales Rep, Shop Manager, Manager,
|
||||
Quality Manager, Owner; Sales Manager via Sales Rep), so they
|
||||
all inherit contact-creation. Pure Technicians do NOT imply
|
||||
office_user, so they stay unable to create contacts. -->
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_partner_manager'))]"/>
|
||||
<field name="comment">Marker group that controls visibility of
|
||||
non-tablet app menus (Calendar, Sales, Inventory, etc.).
|
||||
Implied by every fp role above Technician (Owner, Manager,
|
||||
|
||||
@@ -97,3 +97,7 @@ access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager
|
||||
access_fp_migration_preview_owner,fp.migration.preview.owner,model_fp_migration_preview,fusion_plating.group_fp_owner,1,1,1,1
|
||||
access_fp_migration_preview_line_owner,fp.migration.preview.line.owner,model_fp_migration_preview_line,fusion_plating.group_fp_owner,1,1,1,1
|
||||
access_ir_actions_actions_plating,ir.actions.actions.plating.read,base.model_ir_actions_actions,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_rack_load_tech,fp.rack.load.tech,model_fp_rack_load,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_rack_load_mgr,fp.rack.load.mgr,model_fp_rack_load,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_rack_load_line_tech,fp.rack.load.line.tech,model_fp_rack_load_line,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_rack_load_line_mgr,fp.rack.load.line.mgr,model_fp_rack_load_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -11,3 +11,4 @@ from . import test_landing_resolver
|
||||
from . import test_team_page
|
||||
from . import test_sales_manager_gate
|
||||
from . import test_migration_workflow
|
||||
from . import test_rack_load
|
||||
|
||||
27
fusion_plating/fusion_plating/tests/test_rack_load.py
Normal file
27
fusion_plating/fusion_plating/tests/test_rack_load.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Phase 1 — rack-load core model tests.
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestRackLoad(TransactionCase):
|
||||
|
||||
def test_equal_split_math(self):
|
||||
"""Remainder goes to the first racks (spec D4)."""
|
||||
Load = self.env['fp.rack.load']
|
||||
self.assertEqual(Load._fp_equal_split(100, 1), [100])
|
||||
self.assertEqual(Load._fp_equal_split(100, 2), [50, 50])
|
||||
self.assertEqual(Load._fp_equal_split(100, 3), [34, 33, 33])
|
||||
self.assertEqual(Load._fp_equal_split(100, 4), [25, 25, 25, 25])
|
||||
self.assertEqual(Load._fp_equal_split(10, 3), [4, 3, 3])
|
||||
self.assertEqual(Load._fp_equal_split(0, 3), [0, 0, 0])
|
||||
self.assertEqual(Load._fp_equal_split(5, 0), [])
|
||||
# sums always equal the total
|
||||
self.assertEqual(sum(Load._fp_equal_split(97, 6)), 97)
|
||||
|
||||
def test_create_sequence_and_qty_total(self):
|
||||
rack = self.env['fusion.plating.rack'].create({'name': 'RL-TEST-RACK'})
|
||||
load = self.env['fp.rack.load'].create({'rack_id': rack.id})
|
||||
self.assertTrue(load.name.startswith('RACKLOAD/'))
|
||||
self.assertEqual(load.state, 'loading')
|
||||
self.assertEqual(load.qty_total, 0)
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.9.3.0',
|
||||
'version': '19.0.10.3.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Migrate the single Default CoC Contact (Many2one) to the multi-contact
|
||||
Many2many.
|
||||
|
||||
The field x_fc_default_coc_contact_id (Many2one column on res_partner) was
|
||||
renamed to x_fc_default_coc_contact_ids (self-referential Many2many, rel
|
||||
table fp_default_coc_contact_rel). Odoo creates the new rel table during the
|
||||
schema-update phase but leaves the old column orphaned. Copy each partner's
|
||||
single contact into the new M2m so existing per-customer CoC routing carries
|
||||
over, then drop the dead column. Idempotent + guarded on column existence.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Old column still present (Odoo doesn't drop removed-field columns)?
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'res_partner'
|
||||
AND column_name = 'x_fc_default_coc_contact_id'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
return
|
||||
# New M2m rel table created by the schema update before this runs.
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'fp_default_coc_contact_rel'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning(
|
||||
'fp_default_coc_contact_rel missing — skipping CoC contact '
|
||||
'migration (rel table not created yet).')
|
||||
return
|
||||
# Copy the single value into the M2m (skip rows already present so a
|
||||
# re-run is harmless).
|
||||
cr.execute("""
|
||||
INSERT INTO fp_default_coc_contact_rel (partner_id, contact_id)
|
||||
SELECT p.id, p.x_fc_default_coc_contact_id
|
||||
FROM res_partner p
|
||||
WHERE p.x_fc_default_coc_contact_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fp_default_coc_contact_rel r
|
||||
WHERE r.partner_id = p.id
|
||||
AND r.contact_id = p.x_fc_default_coc_contact_id)
|
||||
""")
|
||||
moved = cr.rowcount
|
||||
cr.execute(
|
||||
"ALTER TABLE res_partner DROP COLUMN IF EXISTS "
|
||||
"x_fc_default_coc_contact_id")
|
||||
_logger.info(
|
||||
'CoC contact migration: copied %s single Default-CoC-Contact value(s) '
|
||||
'into the new x_fc_default_coc_contact_ids M2m, dropped old column.',
|
||||
moved)
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Migrate the cert's single Customer Contact (Many2one) to a Many2many.
|
||||
|
||||
fp.certificate.contact_partner_id (single Many2one column) was renamed to
|
||||
contact_partner_ids (Many2many -> res.partner, rel
|
||||
fp_certificate_contact_partner_rel) so a cert can carry every contact who
|
||||
receives the CoC. Odoo creates the new rel table during the schema-update
|
||||
phase but leaves the old column orphaned. Copy each cert's single contact
|
||||
into the new M2m (becomes the primary / printed addressee), then drop the
|
||||
dead column. Guarded + idempotent.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'fp_certificate'
|
||||
AND column_name = 'contact_partner_id'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
return
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'fp_certificate_contact_partner_rel'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning(
|
||||
'fp_certificate_contact_partner_rel missing — skipping cert '
|
||||
'customer-contact migration (rel table not created yet).')
|
||||
return
|
||||
cr.execute("""
|
||||
INSERT INTO fp_certificate_contact_partner_rel (cert_id, partner_id)
|
||||
SELECT c.id, c.contact_partner_id
|
||||
FROM fp_certificate c
|
||||
WHERE c.contact_partner_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fp_certificate_contact_partner_rel r
|
||||
WHERE r.cert_id = c.id
|
||||
AND r.partner_id = c.contact_partner_id)
|
||||
""")
|
||||
moved = cr.rowcount
|
||||
cr.execute(
|
||||
"ALTER TABLE fp_certificate DROP COLUMN IF EXISTS contact_partner_id")
|
||||
_logger.info(
|
||||
'Cert customer-contact migration: copied %s single contact_partner_id '
|
||||
'value(s) into the new contact_partner_ids M2m, dropped old column.',
|
||||
moved)
|
||||
@@ -58,11 +58,18 @@ class FpCertificate(models.Model):
|
||||
string='Customer Job No.',
|
||||
help="Customer's internal job / traveler reference.",
|
||||
)
|
||||
contact_partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer Contact',
|
||||
contact_partner_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
relation='fp_certificate_contact_partner_rel',
|
||||
column1='cert_id', column2='partner_id',
|
||||
string='Customer Contact',
|
||||
domain="[('parent_id', '=', partner_id)]",
|
||||
help="Specific contact person at the customer for this certificate. "
|
||||
'Their name, email, and phone are printed on the CoC.',
|
||||
help="Contacts at the customer who receive this certificate. "
|
||||
"Auto-filled from the customer's Default CoC Contacts when a "
|
||||
'job ships. The first is the primary (its name, email and '
|
||||
'phone print on the CoC); ALL of them are emailed when the '
|
||||
'cert is sent to the customer. (Renamed from the single '
|
||||
'contact_partner_id — see migration 19.0.10.3.0.)',
|
||||
)
|
||||
issued_by_id = fields.Many2one(
|
||||
'res.users', string='Issued By', default=lambda self: self.env.user,
|
||||
@@ -436,6 +443,47 @@ class FpCertificate(models.Model):
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
def _fp_needs_thickness_data(self):
|
||||
"""True when this cert MUST carry thickness data to be issued.
|
||||
|
||||
Single source of truth shared by action_issue (hard gate) and the
|
||||
Issue-Certs wizard (readiness hint) so the two can never drift.
|
||||
|
||||
Partner side — the ceiling: a CoC needs thickness when the customer
|
||||
is strict-thickness (aerospace / Nadcap) or opts into the
|
||||
thickness-on-CoC bundle; a thickness_report cert always needs it.
|
||||
|
||||
Recipe side — suppress-only: a recipe whose requires_thickness_report
|
||||
is False (passivation, chemical conversion, anodize seal-only — no
|
||||
plating thickness physically exists) REMOVES the requirement even
|
||||
when the customer asked. This mirrors Step 2 of
|
||||
fp.job._resolve_required_cert_types so the cert-type resolver and
|
||||
this issue-time gate agree. Without it, a passivation CoC for a
|
||||
thickness customer can never be issued (the gate demands Fischerscope
|
||||
data the process cannot produce). Field-existence guards keep this
|
||||
safe when fusion_plating_jobs / fusion_plating are at an older
|
||||
schema or not installed.
|
||||
"""
|
||||
self.ensure_one()
|
||||
partner = self.partner_id
|
||||
needs = (
|
||||
self.certificate_type == 'thickness_report'
|
||||
or (self.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if not needs:
|
||||
return False
|
||||
job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False
|
||||
recipe = job.recipe_id if job else False
|
||||
if (recipe and 'requires_thickness_report' in recipe._fields
|
||||
and not recipe.requires_thickness_report):
|
||||
return False
|
||||
return True
|
||||
|
||||
# ----- State actions ----------------------------------------------------
|
||||
def action_issue(self):
|
||||
# ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
|
||||
@@ -478,12 +526,15 @@ class FpCertificate(models.Model):
|
||||
# was configured would still trip the gate even after sales
|
||||
# set the default. Robust-by-construction: the defaults take
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_id
|
||||
if (not rec.contact_partner_ids
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_ids):
|
||||
# Auto-fill ALL the customer's CoC contacts. The first is
|
||||
# the primary (printed on the CoC); every contact is emailed
|
||||
# when the cert is sent (action_send_to_customer).
|
||||
rec.contact_partner_ids = (
|
||||
rec.partner_id.x_fc_default_coc_contact_ids
|
||||
)
|
||||
# Lazy-fill the signer from the LIVE company owner (Settings
|
||||
# "Certificate Owner") when no per-cert / per-spec signer was
|
||||
@@ -537,27 +588,27 @@ class FpCertificate(models.Model):
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
if not rec.contact_partner_id:
|
||||
# cert and emailed when it ships. Auto-filled from the FIRST
|
||||
# of partner.x_fc_default_coc_contact_ids when set.
|
||||
if not rec.contact_partner_ids:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'Contact is not set.\n\nPick the recipient contact(s), '
|
||||
'or configure Default CoC Contacts on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||
})
|
||||
if not (rec.contact_partner_id.email or '').strip():
|
||||
if not (rec.contact_partner_ids[:1].email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email '
|
||||
'to the contact before issuing (the cert is sent '
|
||||
'by email post-issue).'
|
||||
'Cannot issue certificate "%(name)s" — primary contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email to the '
|
||||
'contact before issuing (the cert is sent by email '
|
||||
'post-issue).'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'c': rec.contact_partner_id.name,
|
||||
'c': rec.contact_partner_ids[:1].name,
|
||||
})
|
||||
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
||||
# are manual-attach only — operator uploads supplier doc /
|
||||
@@ -580,30 +631,18 @@ class FpCertificate(models.Model):
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
})
|
||||
# Thickness data requirement — unified gate covering both
|
||||
# cert types. A customer needs thickness data on the cert
|
||||
# when ANY of these is true:
|
||||
# 1. cert type is thickness_report (the cert IS the data)
|
||||
# 2. partner.x_fc_strict_thickness_required (aerospace /
|
||||
# Nadcap — always strict)
|
||||
# 3. partner.x_fc_send_thickness_report (the bundling
|
||||
# rule — CoC carries thickness as page 2 by default
|
||||
# for these customers; see CLAUDE.md "CoC + thickness
|
||||
# = ONE cert (page 2 merge)")
|
||||
# Acceptable data: logged readings on the cert OR a
|
||||
# Fischerscope PDF on the linked QC OR a cert-local
|
||||
# Fischerscope upload. Any one is enough.
|
||||
# Thickness data requirement — _fp_needs_thickness_data is the
|
||||
# single source of truth (shared with the Issue-Certs wizard).
|
||||
# A customer needs thickness data when the cert is a
|
||||
# thickness_report, or it's a CoC and the partner is
|
||||
# strict-thickness / opts into the thickness-on-CoC bundle —
|
||||
# UNLESS the job's recipe suppresses thickness
|
||||
# (requires_thickness_report=False: passivation, chemical
|
||||
# conversion, anodize seal-only — no plating thickness exists).
|
||||
# Acceptable data: logged readings on the cert OR a Fischerscope
|
||||
# PDF on the linked QC OR a cert-local Fischerscope upload.
|
||||
partner = rec.partner_id
|
||||
needs_thickness = (
|
||||
rec.certificate_type == 'thickness_report'
|
||||
or (rec.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if needs_thickness:
|
||||
if rec._fp_needs_thickness_data():
|
||||
has_readings = bool(rec.thickness_reading_ids)
|
||||
has_qc_fischer_pdf = bool(
|
||||
rec.x_fc_thickness_pdf_id
|
||||
@@ -1085,11 +1124,18 @@ class FpCertificate(models.Model):
|
||||
"""Open email composer with the certificate PDF attached."""
|
||||
self.ensure_one()
|
||||
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
|
||||
# Send to ALL the Customer Contacts defined on this cert (auto-filled
|
||||
# from the customer's Default CoC Contacts at job creation), falling
|
||||
# back to the company. The CoC goes to exactly the contacts shown on
|
||||
# the cert's Customer Contact field.
|
||||
recipients = self.contact_partner_ids
|
||||
partner_ids = recipients.ids or (
|
||||
[self.partner_id.id] if self.partner_id else [])
|
||||
ctx = {
|
||||
'default_model': 'fp.certificate',
|
||||
'default_res_ids': self.ids,
|
||||
'default_composition_mode': 'comment',
|
||||
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
|
||||
'default_partner_ids': partner_ids,
|
||||
}
|
||||
if self.attachment_id:
|
||||
ctx['default_attachment_ids'] = [self.attachment_id.id]
|
||||
|
||||
@@ -128,17 +128,24 @@ class ResPartner(models.Model):
|
||||
'who require specific NIST or DFARS language.',
|
||||
)
|
||||
|
||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
||||
# The single named contact printed on the CoC and used as the email
|
||||
# default when the cert ships. Sales sets it once per customer.
|
||||
# Falls back to manual selection at action_issue time if blank.
|
||||
x_fc_default_coc_contact_id = fields.Many2one(
|
||||
# ---- Default CoC contacts (cert addressees + email recipients) -------
|
||||
# One or more named contacts who receive this customer's CoC. The first
|
||||
# is the primary (printed on the cert + pre-fills cert.contact_partner_id
|
||||
# when a job ships); the rest are CC'd when the CoC is emailed. Sales
|
||||
# sets the list once per customer. Falls back to manual selection at
|
||||
# action_issue time if blank. Self-referential M2m (renamed from the old
|
||||
# single Many2one x_fc_default_coc_contact_id — see migration 19.0.10.2.0).
|
||||
x_fc_default_coc_contact_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
string='Default CoC Contact',
|
||||
relation='fp_default_coc_contact_rel',
|
||||
column1='partner_id', column2='contact_id',
|
||||
string='Default CoC Contacts',
|
||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||
tracking=True,
|
||||
help='Default contact the Certificate of Conformance is addressed '
|
||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
||||
'job ships. Leave blank to force the manager to pick at '
|
||||
'issue time. Must be a child contact of this company.',
|
||||
help='Contacts the Certificate of Conformance is addressed to and '
|
||||
'emailed to. The first contact is the primary (printed on the '
|
||||
'cert and pre-filled as the cert contact when a job ships); '
|
||||
'the rest are copied (CC) when the CoC is sent. Leave blank to '
|
||||
'force the manager to pick at issue time. Child contacts of '
|
||||
'this company only.',
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||
'certified_by_id': self.signer.id,
|
||||
'contact_partner_id': self.contact_with_email.id,
|
||||
'contact_partner_ids': [(6, 0, [self.contact_with_email.id])],
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.certificate'].create(vals)
|
||||
@@ -85,13 +85,13 @@ class TestActionIssueGates(TransactionCase):
|
||||
# ---- new gate: contact_partner_id ----
|
||||
|
||||
def test_blocks_on_missing_contact(self):
|
||||
cert = self._make_cert(contact_partner_id=False)
|
||||
cert = self._make_cert(contact_partner_ids=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Customer Contact', str(exc.exception))
|
||||
|
||||
def test_blocks_on_contact_without_email(self):
|
||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
||||
cert = self._make_cert(contact_partner_ids=[(6, 0, [self.contact_no_email.id])])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('no email', str(exc.exception))
|
||||
@@ -113,7 +113,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
spec_reference=False,
|
||||
process_description=False,
|
||||
certified_by_id=False,
|
||||
contact_partner_id=False,
|
||||
contact_partner_ids=False,
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
|
||||
@@ -101,7 +101,8 @@
|
||||
<group>
|
||||
<field name="certificate_type"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="contact_partner_id"
|
||||
<field name="contact_partner_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
invisible="not partner_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
|
||||
@@ -44,15 +44,17 @@
|
||||
<field name="x_fc_send_customer_specific" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Default CoC Contact"/>
|
||||
<separator string="Default CoC Contacts"/>
|
||||
<p class="text-muted">
|
||||
The named contact this customer's CoC is addressed
|
||||
to and emailed to. Pre-fills cert records when a
|
||||
job ships. Leave blank to force the manager to pick
|
||||
at issue time.
|
||||
The contacts this customer's CoC is addressed to and
|
||||
emailed to. The first is the primary (printed on the
|
||||
cert); the rest are copied (CC) when the CoC is sent.
|
||||
Pre-fills cert records when a job ships. Leave blank
|
||||
to force the manager to pick at issue time.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_default_coc_contact_id"
|
||||
<field name="x_fc_default_coc_contact_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.22.10.0',
|
||||
'version': '19.0.22.13.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -79,6 +79,7 @@ class FpPartCatalog(models.Model):
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer', required=True, ondelete='cascade',
|
||||
tracking=True, domain="[('customer_rank', '>', 0)]",
|
||||
context={'default_customer_rank': 1}, # inline-created customers get rank=1 so they stay visible in this picker
|
||||
)
|
||||
part_number = fields.Char(string='Part Number', required=True, tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
|
||||
revision = fields.Char(
|
||||
|
||||
@@ -52,6 +52,7 @@ class FpQuoteConfigurator(models.Model):
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer', required=True,
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
context={'default_customer_rank': 1}, # inline-created customers get rank=1 so they stay visible in this picker
|
||||
)
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part (Catalog)',
|
||||
|
||||
@@ -350,6 +350,14 @@ class SaleOrderLine(models.Model):
|
||||
'steps run, with this text shown on the operator tablet under '
|
||||
'fp.job.step.instructions.',
|
||||
)
|
||||
x_fc_masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'sale_order_line_masking_att_rel', 'line_id', 'attachment_id',
|
||||
string='Masking Reference(s)',
|
||||
help='Masking reference image(s)/PDF(s) captured at Express order '
|
||||
'entry; applied to the job\'s masking step at job creation so '
|
||||
'the operator sees what to mask.',
|
||||
)
|
||||
x_fc_revision_snapshot = fields.Char(
|
||||
string='Revision (snapshot)',
|
||||
copy=False,
|
||||
@@ -840,6 +848,19 @@ class SaleOrderLine(models.Model):
|
||||
})
|
||||
if nodes:
|
||||
msgs.append(_('Masking + de-masking steps opted out (per SO line)'))
|
||||
elif self.x_fc_masking_attachment_ids:
|
||||
# Masking ON + Express reference file(s) attached → surface them on
|
||||
# the mask step so the operator sees what to mask. Lands on the
|
||||
# second call (after steps exist), same as bake below.
|
||||
mask_steps = job.step_ids.filtered(
|
||||
lambda s: s.recipe_node_id.default_kind == 'mask'
|
||||
)
|
||||
if mask_steps:
|
||||
mask_steps.sudo().write({
|
||||
'x_fc_masking_attachment_ids': [(6, 0, self.x_fc_masking_attachment_ids.ids)],
|
||||
})
|
||||
msgs.append(_('Masking reference(s) attached to the mask step: %d file(s)')
|
||||
% len(self.x_fc_masking_attachment_ids))
|
||||
|
||||
# 2. Bake — empty = opt out; non-empty = keep + write step.instructions
|
||||
bake_text = (self.x_fc_bake_instructions or '').strip()
|
||||
|
||||
@@ -91,6 +91,67 @@ export class FpExpressActionBtns extends Component {
|
||||
);
|
||||
if (action) await this.action.doAction(action);
|
||||
}
|
||||
|
||||
// ---- Masking reference upload (2026-06-03) ----
|
||||
// Visible only when masking is toggled ON for this line. Accepts MULTIPLE
|
||||
// image/PDF files; each is attached to the line and (on order confirm)
|
||||
// copied onto the job's masking step so the operator sees it in the
|
||||
// workstation. Mirrors onUpload but loops over the file list.
|
||||
get maskingEnabled() {
|
||||
return !!this.props.record.data.masking_enabled;
|
||||
}
|
||||
|
||||
get maskCount() {
|
||||
const m = this.props.record.data.masking_attachment_ids;
|
||||
return (m && m.count) || 0;
|
||||
}
|
||||
|
||||
async onUploadMask(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = ".pdf,.png,.jpg,.jpeg,application/pdf,image/*";
|
||||
input.onchange = async () => {
|
||||
const files = Array.from(input.files || []);
|
||||
if (!files.length) return;
|
||||
if (!(await this._ensureSaved())) return;
|
||||
let ok = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
const base64 = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result.split(",")[1]);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
"action_upload_masking_ref",
|
||||
[[this.props.record.resId]],
|
||||
{
|
||||
context: {
|
||||
fp_masking_file: base64,
|
||||
fp_masking_filename: file.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
ok += 1;
|
||||
} catch (e) {
|
||||
this.notification.add(
|
||||
`Masking upload failed for "${file.name}": ${e.message || e}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
this.notification.add(`${ok} masking reference(s) added.`, { type: "success" });
|
||||
await this.props.record.load();
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
||||
export const fpExpressActionBtns = {
|
||||
|
||||
@@ -441,6 +441,21 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// MASK upload — amber so order-entry notices the "attach reference"
|
||||
// affordance the moment masking is toggled on. Solid amber works on
|
||||
// both the light and dark backend bundles (dark text on amber fill).
|
||||
.o_fp_xpr_mask_btn {
|
||||
color: #1f2937;
|
||||
border-color: #d97706;
|
||||
background: #fbbf24;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #1f2937;
|
||||
border-color: #b45309;
|
||||
background: #f59e0b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
title="Open the part record in a modal">
|
||||
OPEN
|
||||
</button>
|
||||
<button t-if="maskingEnabled"
|
||||
class="o_fp_xpr_action_stack_btn o_fp_xpr_mask_btn"
|
||||
t-on-click="onUploadMask"
|
||||
t-att-disabled="!hasPart"
|
||||
title="Attach masking reference image(s)/PDF(s) — shown to the operator on the masking step">
|
||||
MASK<t t-if="maskCount"> (<t t-esc="maskCount"/>)</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
<field name="customer_line_ref" string="Line Job #" placeholder="ABC" width="80px"/>
|
||||
<field name="thickness_range" string="Thickness" placeholder=".0005-.0010" width="100px"/>
|
||||
<field name="masking_enabled" string="Mask" widget="boolean_toggle" width="55px"/>
|
||||
<field name="masking_attachment_ids" column_invisible="1"/>
|
||||
<!-- Bake pill — click to edit -->
|
||||
<field name="bake_instructions"
|
||||
string="Bake"
|
||||
|
||||
@@ -573,6 +573,14 @@ class FpDirectOrderLine(models.Model):
|
||||
help='Free-text bake instructions. Empty = bake steps are opted out. '
|
||||
'Non-empty = bake step instructions on the operator tablet.',
|
||||
)
|
||||
masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fp_direct_order_line_masking_att_rel', 'line_id', 'attachment_id',
|
||||
string='Masking Reference(s)',
|
||||
help='Image(s)/PDF(s) of what to mask. Carried to the SO line and '
|
||||
'shown to the operator on the job\'s masking step. Only relevant '
|
||||
'when Masking is enabled.',
|
||||
)
|
||||
|
||||
# ---- Computes ----
|
||||
@api.depends('quantity', 'unit_price')
|
||||
@@ -766,6 +774,29 @@ class FpDirectOrderLine(models.Model):
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_upload_masking_ref(self):
|
||||
"""Attach a masking reference (image/PDF) to this line.
|
||||
|
||||
Called by the Express 'MASK REF' button — once per file (multi-select
|
||||
loops in JS), via context keys fp_masking_file + fp_masking_filename.
|
||||
Stored on the line's masking_attachment_ids; carried to the SO line
|
||||
and the job's masking step at order confirm.
|
||||
"""
|
||||
self.ensure_one()
|
||||
from odoo.exceptions import UserError
|
||||
file_data = self.env.context.get('fp_masking_file')
|
||||
filename = self.env.context.get('fp_masking_filename', 'masking-ref')
|
||||
if not file_data:
|
||||
raise UserError(_('No file data received.'))
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'datas': file_data,
|
||||
'res_model': 'fp.direct.order.line',
|
||||
'res_id': self.id,
|
||||
})
|
||||
self.write({'masking_attachment_ids': [(4, att.id)]})
|
||||
return True
|
||||
|
||||
def action_upload_drawing(self):
|
||||
"""Attach a file (via context) to the line's part as a drawing.
|
||||
|
||||
|
||||
@@ -62,6 +62,11 @@ class FpDirectOrderWizard(models.Model):
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
# 2026-06-02: default customer_rank=1 so a customer created inline
|
||||
# (quick-create) from the Express Order form is marked as a customer
|
||||
# and stays visible in this picker AND the Customers menu (both filter
|
||||
# customer_rank>0). Without it new customers got rank 0 and vanished.
|
||||
context={'default_customer_rank': 1},
|
||||
tracking=True,
|
||||
)
|
||||
partner_invoice_id = fields.Many2one(
|
||||
@@ -951,6 +956,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_customer_line_ref': line.customer_line_ref or False,
|
||||
'x_fc_masking_enabled': line.masking_enabled,
|
||||
'x_fc_bake_instructions': line.bake_instructions or False,
|
||||
'x_fc_masking_attachment_ids': [(6, 0, line.masking_attachment_ids.ids)],
|
||||
# Sub 9 — explicit tax override from the wizard line.
|
||||
# When blank, Odoo will compute taxes from the product
|
||||
# defaults at SO-line save time (the standard behaviour).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.11.6.0',
|
||||
'version': '19.0.12.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
from . import fp_job_workflow_state # Sub 14 — must load before fp_job (FK target)
|
||||
from . import fp_job
|
||||
from . import fp_job_sticker
|
||||
from . import fp_job_step
|
||||
from . import fp_job_masking
|
||||
from . import fp_job_node_override
|
||||
from . import fp_portal_job
|
||||
from . import account_move
|
||||
@@ -35,6 +37,10 @@ from . import report_fp_job_margin
|
||||
# (fp.qc.checklist.template lives in fusion_plating_quality; can't depend
|
||||
# back on jobs without a cycle.)
|
||||
from . import fp_job_consumption
|
||||
|
||||
# Multi-rack splitting at Racking (Phase 1) — jobs-side extension of
|
||||
# fp.rack.load (core model in fusion_plating) + fp.job rollups.
|
||||
from . import fp_job_rack
|
||||
# fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the
|
||||
# hr.employee shop-roles inherit live in fusion_plating core so every
|
||||
# downstream module (cgp, bridge_mrp residue, etc.) sees them without a
|
||||
|
||||
@@ -2379,7 +2379,14 @@ class FpJob(models.Model):
|
||||
if 'contact_name' in Delivery._fields and ship_to.name:
|
||||
vals['contact_name'] = ship_to.name
|
||||
if 'contact_phone' in Delivery._fields:
|
||||
vals['contact_phone'] = ship_to.phone or ship_to.mobile or ''
|
||||
# res.partner has no `mobile` field in this Odoo 19 build —
|
||||
# guard it so the read can't AttributeError (and still picks
|
||||
# up mobile on instances that do define it).
|
||||
vals['contact_phone'] = (
|
||||
ship_to.phone
|
||||
or (ship_to.mobile if 'mobile' in ship_to._fields else '')
|
||||
or ''
|
||||
)
|
||||
# Scheduled date — operator can adjust; this just primes it
|
||||
# so they're not staring at a blank field.
|
||||
if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
|
||||
@@ -2671,7 +2678,7 @@ class FpJob(models.Model):
|
||||
(a per-spec override). Left empty
|
||||
otherwise so the LIVE company owner
|
||||
resolves at render / issue time.
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all)
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
@@ -2706,8 +2713,11 @@ class FpJob(models.Model):
|
||||
signer = spec.signer_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
|
||||
# ALL the customer's CoC contacts -> the cert's Customer Contact
|
||||
# M2m. First is the primary (printed on the CoC); every contact
|
||||
# is emailed at send (fp.certificate.action_send_to_customer).
|
||||
contact = self.partner_id.x_fc_default_coc_contact_ids
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
@@ -2777,8 +2787,8 @@ class FpJob(models.Model):
|
||||
vals['process_description'] = recipe.name or ''
|
||||
if 'certified_by_id' in Cert._fields and signer:
|
||||
vals['certified_by_id'] = signer.id
|
||||
if 'contact_partner_id' in Cert._fields and contact:
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'contact_partner_ids' in Cert._fields and contact:
|
||||
vals['contact_partner_ids'] = [(6, 0, contact.ids)]
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
|
||||
36
fusion_plating/fusion_plating_jobs/models/fp_job_masking.py
Normal file
36
fusion_plating/fusion_plating_jobs/models/fp_job_masking.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Masking reference attachments — captured at Express order entry, surfaced
|
||||
on the job's masking step (operator workstation) and rolled up to the job
|
||||
form (office). Populated by sale.order.line._fp_apply_express_overrides_to_job.
|
||||
"""
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
_inherit = 'fp.job.step'
|
||||
|
||||
x_fc_masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fp_job_step_masking_att_rel', 'step_id', 'attachment_id',
|
||||
string='Masking Reference(s)',
|
||||
help='Reference image(s)/PDF(s) of what to mask, attached at order '
|
||||
'entry (Express) and shown to the operator on the masking step.',
|
||||
)
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
x_fc_masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
compute='_compute_masking_attachment_ids',
|
||||
string='Masking References',
|
||||
help='All masking reference files across this job\'s masking steps.',
|
||||
)
|
||||
|
||||
@api.depends('step_ids.x_fc_masking_attachment_ids')
|
||||
def _compute_masking_attachment_ids(self):
|
||||
for job in self:
|
||||
atts = job.step_ids.mapped('x_fc_masking_attachment_ids')
|
||||
job.x_fc_masking_attachment_ids = [(6, 0, atts.ids)]
|
||||
166
fusion_plating/fusion_plating_jobs/models/fp_job_rack.py
Normal file
166
fusion_plating/fusion_plating_jobs/models/fp_job_rack.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Multi-rack splitting at Racking — Phase 1 jobs-module extension.
|
||||
# Core models live in fusion_plating/models/fp_rack_load.py. This file owns
|
||||
# everything that touches jobs-module fields (fp.job.step.area_kind,
|
||||
# fp.job.part_catalog_id) and the racking-step detection (_fp_is_racking_step).
|
||||
# Spec/plan: docs/superpowers/{specs,plans}/2026-06-03-racking-multi-rack-*.md
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpRackLoad(models.Model):
|
||||
_inherit = 'fp.rack.load'
|
||||
|
||||
current_area_kind = fields.Char(
|
||||
string='Current Area', compute='_compute_current_area_kind', store=True)
|
||||
|
||||
@api.depends('current_step_id.area_kind')
|
||||
def _compute_current_area_kind(self):
|
||||
for load in self:
|
||||
load.current_area_kind = load.current_step_id.area_kind or False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Racking-step resolution + the "total parts available to rack"
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _fp_racking_step_for(self, job):
|
||||
# Detect the racking step by area_kind == 'racking' (the corrected
|
||||
# classification), NOT _fp_is_racking_step() — the latter keys off the
|
||||
# step's kind, and de-racking steps are frequently mis-tagged
|
||||
# kind='racking' in the data, which would wrongly match De-Racking.
|
||||
return job.step_ids.filtered(lambda s: s.area_kind == 'racking')[:1]
|
||||
|
||||
@api.model
|
||||
def _fp_racking_total(self, job):
|
||||
step = self._fp_racking_step_for(job)
|
||||
if step and step.qty_at_step:
|
||||
return int(step.qty_at_step)
|
||||
return int(job.qty or 0)
|
||||
|
||||
@api.model
|
||||
def _fp_job_loads(self, job):
|
||||
"""Active (not unracked/cancelled) loads carrying this job's parts."""
|
||||
return self.search([
|
||||
('line_ids.job_id', '=', job.id),
|
||||
('state', 'in', ('loading', 'loaded', 'running')),
|
||||
], order='id')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Division API (operator's split + manual override)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _fp_split_job(self, job, n):
|
||||
"""(Re)create n loads for `job`, equal split of the racking total.
|
||||
|
||||
Drops existing unmoved 'loading' loads first. Moved/assigned loads are
|
||||
left alone (can't re-split parts that already advanced)."""
|
||||
total = self._fp_racking_total(job)
|
||||
self._fp_job_loads(job).filtered(
|
||||
lambda l: l.state == 'loading' and not l.current_step_id).unlink()
|
||||
qtys = self._fp_equal_split(total, max(int(n), 1))
|
||||
loads = self.browse()
|
||||
for q in qtys:
|
||||
loads |= self.create({
|
||||
'line_ids': [(0, 0, {'job_id': job.id, 'qty': q})],
|
||||
})
|
||||
return loads
|
||||
|
||||
@api.model
|
||||
def _fp_ensure_seeded(self, job):
|
||||
"""Default state: one rack carrying all the parts."""
|
||||
if not self._fp_job_loads(job):
|
||||
self._fp_split_job(job, 1)
|
||||
return self._fp_job_loads(job)
|
||||
|
||||
@api.model
|
||||
def _fp_add_rack(self, job):
|
||||
return self._fp_split_job(job, len(self._fp_job_loads(job)) + 1)
|
||||
|
||||
@api.model
|
||||
def _fp_divide_equally(self, job):
|
||||
return self._fp_split_job(job, max(len(self._fp_job_loads(job)), 1))
|
||||
|
||||
def _fp_set_qty(self, qty):
|
||||
"""Manual override of a single load's quantity (must not exceed the
|
||||
job's available parts across all its loads)."""
|
||||
self.ensure_one()
|
||||
line = self.line_ids[:1]
|
||||
if not line:
|
||||
raise UserError(_('This rack has no work order line.'))
|
||||
job = line.job_id
|
||||
total = self._fp_racking_total(job)
|
||||
other = sum((self._fp_job_loads(job) - self).mapped('qty_total'))
|
||||
if other + int(qty) > total:
|
||||
raise UserError(
|
||||
_('Assigned %(a)s exceeds the %(t)s parts available to rack.')
|
||||
% {'a': other + int(qty), 't': total})
|
||||
line.qty = int(qty)
|
||||
|
||||
def _fp_remove_rack(self):
|
||||
self.ensure_one()
|
||||
if self.current_step_id:
|
||||
raise UserError(_('Cannot remove a rack that has already moved.'))
|
||||
self.unlink()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Independent movement + de-racking
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_advance_to(self, to_step):
|
||||
"""Move these rack-loads to `to_step`: one move row per line (per WO),
|
||||
carrying the rack + line qty, then update position/state."""
|
||||
Move = self.env['fp.job.step.move']
|
||||
for load in self:
|
||||
from_step = load.current_step_id
|
||||
for line in load.line_ids:
|
||||
Move.create({
|
||||
'job_id': line.job_id.id,
|
||||
'from_step_id': from_step.id if from_step else False,
|
||||
'to_step_id': to_step.id,
|
||||
'qty_moved': line.qty,
|
||||
'rack_id': load.rack_id.id if load.rack_id else False,
|
||||
'transfer_type': 'step',
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
load.current_step_id = to_step
|
||||
load.state = 'running'
|
||||
|
||||
def _fp_unrack(self):
|
||||
"""De-Racking: free the physical rack; each line's parts continue in
|
||||
its own job's flow (the per-line moves already attributed qty)."""
|
||||
for load in self:
|
||||
load.state = 'unracked'
|
||||
if load.rack_id:
|
||||
load.rack_id.racking_state = 'empty'
|
||||
|
||||
|
||||
class FpRackLoadLine(models.Model):
|
||||
_inherit = 'fp.rack.load.line'
|
||||
|
||||
part_catalog_id = fields.Many2one(
|
||||
related='job_id.part_catalog_id', store=True, string='Part')
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
rack_load_line_ids = fields.One2many(
|
||||
'fp.rack.load.line', 'job_id', string='Rack Loads')
|
||||
qty_racked = fields.Integer(
|
||||
string='Parts Racked', compute='_compute_qty_racked')
|
||||
qty_unracked = fields.Integer(
|
||||
string='Parts Unassigned', compute='_compute_qty_racked')
|
||||
|
||||
@api.depends('rack_load_line_ids.qty', 'rack_load_line_ids.load_id.state')
|
||||
def _compute_qty_racked(self):
|
||||
Load = self.env['fp.rack.load']
|
||||
for job in self:
|
||||
active = job.rack_load_line_ids.filtered(
|
||||
lambda l: l.load_id.state in ('loading', 'loaded', 'running'))
|
||||
job.qty_racked = sum(active.mapped('qty'))
|
||||
total = Load._fp_racking_total(job)
|
||||
job.qty_unracked = max(total - job.qty_racked, 0)
|
||||
@@ -157,33 +157,122 @@ class FpJobStep(models.Model):
|
||||
@api.depends(
|
||||
'work_centre_id.area_kind',
|
||||
'recipe_node_id.kind_id.area_kind',
|
||||
'name',
|
||||
'recipe_node_id.kind_id.code',
|
||||
'sequence',
|
||||
'job_id.step_ids.sequence',
|
||||
'job_id.step_ids.name',
|
||||
'job_id.step_ids.work_centre_id.area_kind',
|
||||
'job_id.step_ids.recipe_node_id.kind_id.area_kind',
|
||||
'job_id.step_ids.recipe_node_id.kind_id.code',
|
||||
)
|
||||
def _compute_area_kind(self):
|
||||
"""Resolve the plant-view column this step belongs in.
|
||||
|
||||
Priority chain:
|
||||
1. work_centre.area_kind (explicit operator setup wins)
|
||||
2. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
|
||||
3. catch-all 'plating' (data integrity issue if we land here)
|
||||
Priority chain (non-gating steps):
|
||||
1. step-NAME override for unambiguous de-rack / de-mask / bake
|
||||
steps (2026-06-03) — their recipe kind and/or work-centre is
|
||||
frequently wrong (tagged 'racking'/'mask', a shared station, or
|
||||
left blank), scattering cards across the Racking / Masking /
|
||||
Plating columns. The operator-facing NAME is unambiguous, so it
|
||||
wins OUTRIGHT — even over an explicit work-centre. Bake/oven
|
||||
steps that merely mention "de-rack" stay in Baking. See spec
|
||||
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
||||
2. work_centre.area_kind (explicit operator setup)
|
||||
3. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
|
||||
4. catch-all 'plating' (data integrity issue if we land here)
|
||||
|
||||
The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind
|
||||
now self-declares its area_kind, so the kind taxonomy IS the
|
||||
source of truth. See spec
|
||||
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
||||
Gating/marker steps (kind `code == 'gating'` — the "Ready for X"
|
||||
steps) have NO physical location; the taxonomy maps them to
|
||||
'receiving', which made a mid-recipe gate snap the job's card back
|
||||
to the first column (Racking -> "Ready for processing" jumped to
|
||||
Receiving, so the job looked like it vanished — 2026-06-02). A
|
||||
gating step FALLS FORWARD to the next non-gating step's column
|
||||
(it's "ready for [that stage]"), keeping the card moving
|
||||
left->right. If nothing real follows, it falls back to the last
|
||||
real stage.
|
||||
"""
|
||||
for step in self:
|
||||
# 1. Explicit work_centre wins
|
||||
if step.work_centre_id and step.work_centre_id.area_kind:
|
||||
step.area_kind = step.work_centre_id.area_kind
|
||||
continue
|
||||
# 2. Kind taxonomy
|
||||
node = step.recipe_node_id
|
||||
if node and node.kind_id and node.kind_id.area_kind:
|
||||
step.area_kind = node.kind_id.area_kind
|
||||
continue
|
||||
# 3. Catch-all — only reached for orphaned steps (no
|
||||
# work_centre AND no recipe_node).
|
||||
step.area_kind = 'plating'
|
||||
step.area_kind = step._fp_resolve_area_kind()
|
||||
|
||||
def _fp_raw_area_kind(self):
|
||||
"""Area from this step's OWN name / work_centre / kind only — no
|
||||
look-ahead and no dependence on the computed `area_kind` field (so
|
||||
the gating fall-forward below can't recurse).
|
||||
|
||||
Name override (de-rack/de-mask -> De-Racking, bake/oven -> Baking)
|
||||
wins OUTRIGHT: the authored kind / work-centre is frequently
|
||||
wrong/blank for these. See _fp_area_from_step_name."""
|
||||
self.ensure_one()
|
||||
name_area = self._fp_area_from_step_name(self.name)
|
||||
if name_area:
|
||||
return name_area
|
||||
if self.work_centre_id and self.work_centre_id.area_kind:
|
||||
return self.work_centre_id.area_kind
|
||||
node = self.recipe_node_id
|
||||
if node and node.kind_id and node.kind_id.area_kind:
|
||||
return node.kind_id.area_kind
|
||||
return 'plating'
|
||||
|
||||
def _fp_is_gating_step(self):
|
||||
"""True for a 'Ready for X' marker step (no physical location).
|
||||
Detected via the STABLE kind code, never the display name."""
|
||||
self.ensure_one()
|
||||
node = self.recipe_node_id
|
||||
return bool(node and node.kind_id and node.kind_id.code == 'gating')
|
||||
|
||||
def _fp_resolve_area_kind(self):
|
||||
"""Column for this step: its own raw area, EXCEPT a gating marker
|
||||
falls forward to the next non-gating step's column."""
|
||||
self.ensure_one()
|
||||
if not self._fp_is_gating_step():
|
||||
return self._fp_raw_area_kind()
|
||||
siblings = self.job_id.step_ids
|
||||
later = siblings.filtered(
|
||||
lambda s: s.sequence > self.sequence and not s._fp_is_gating_step()
|
||||
).sorted('sequence')
|
||||
if later:
|
||||
return later[0]._fp_raw_area_kind()
|
||||
earlier = siblings.filtered(
|
||||
lambda s: s.sequence < self.sequence and not s._fp_is_gating_step()
|
||||
).sorted('sequence')
|
||||
if earlier:
|
||||
return earlier[-1]._fp_raw_area_kind()
|
||||
return self._fp_raw_area_kind()
|
||||
|
||||
@staticmethod
|
||||
def _fp_area_from_step_name(name):
|
||||
"""Unambiguous step-name -> area_kind override (area or None).
|
||||
|
||||
The recipe kind is frequently wrong/blank for these step types, so
|
||||
the operator-facing NAME is the more reliable signal and wins over
|
||||
kind/work-centre in _compute_area_kind:
|
||||
|
||||
- bake / oven -> 'baking' (checked FIRST so "Oven bake (Post
|
||||
de-rack)" counts as a bake, not a de-rack). Excludes
|
||||
inspection-of-bake names ("post-bake inspection/QC/test") and
|
||||
part-number / generic references ("General Processing -
|
||||
BAKE-K464034") so only real bake operations move.
|
||||
- de-rack / de-mask -> 'de_racking'.
|
||||
|
||||
Everything else returns None so the normal work-centre / kind /
|
||||
fallback chain applies.
|
||||
"""
|
||||
x = (name or '').strip().lower()
|
||||
if not x:
|
||||
return None
|
||||
# bake / oven first — a "post de-rack" oven bake IS a bake
|
||||
if 'oven' in x or 'bake' in x:
|
||||
if any(w in x for w in (
|
||||
'processing', 'inspect', 'check', 'qc',
|
||||
'test', 'verif', 'review')):
|
||||
return None
|
||||
return 'baking'
|
||||
# de-rack / de-mask
|
||||
flat = x.replace('-', '').replace('_', '').replace(' ', '')
|
||||
if 'derack' in flat or 'demask' in flat:
|
||||
return 'de_racking'
|
||||
return None
|
||||
|
||||
last_activity_at = fields.Datetime(
|
||||
string='Last Activity',
|
||||
@@ -397,6 +486,45 @@ class FpJobStep(models.Model):
|
||||
step.state = 'cancelled'
|
||||
return True
|
||||
|
||||
def button_reset(self):
|
||||
"""Reset a step back to 'ready' so it can be redone — operator
|
||||
self-serve for a mistake, an accidental skip, or a customer redo
|
||||
request. Clears the finish + sign-off stamps and closes any open
|
||||
timelog so the redo re-captures them; KEEPS the first-start audit
|
||||
(date_started / started_by) and the move / CoC history intact.
|
||||
Posts a chatter audit on the parent job. No-op on a step already
|
||||
ready/pending (nothing to undo).
|
||||
"""
|
||||
now = fields.Datetime.now()
|
||||
for step in self:
|
||||
if step.state in ('ready', 'pending'):
|
||||
continue
|
||||
prev_label = dict(
|
||||
step._fields['state'].selection
|
||||
).get(step.state, step.state)
|
||||
open_logs = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished)
|
||||
if open_logs:
|
||||
open_logs.write({'date_finished': now, 'state': 'stopped'})
|
||||
vals = {'state': 'ready'}
|
||||
if 'date_finished' in step._fields:
|
||||
vals['date_finished'] = False
|
||||
if 'finished_by_user_id' in step._fields:
|
||||
vals['finished_by_user_id'] = False
|
||||
if 'signoff_user_id' in step._fields:
|
||||
vals['signoff_user_id'] = False
|
||||
step.write(vals)
|
||||
if step.job_id:
|
||||
step.job_id.message_post(body=_(
|
||||
'Step "%(s)s" reset to Ready (was %(p)s) by %(u)s '
|
||||
'for redo.'
|
||||
) % {
|
||||
's': step.name or '?',
|
||||
'p': prev_label,
|
||||
'u': self.env.user.display_name,
|
||||
})
|
||||
return True
|
||||
|
||||
def write(self, vals):
|
||||
"""Post a chatter trail on the parent JOB whenever an active
|
||||
step gets reassigned. The step itself already tracks
|
||||
@@ -698,19 +826,29 @@ class FpJobStep(models.Model):
|
||||
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.
|
||||
Fires for any step that actually moved parts OUT and drained to
|
||||
zero — INCLUDING the first/seeded stage (its qty comes from the
|
||||
qty_at_step seed, not a real incoming move). Returns True if the
|
||||
step finished.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'in_progress':
|
||||
return False
|
||||
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
|
||||
# Guard: only auto-finish a step that genuinely moved parts OUT (a
|
||||
# real outgoing move, excluding self-loop measurement moves). The
|
||||
# earlier guard checked _fp_has_real_incoming() — the WRONG
|
||||
# direction: the first/seeded stage (e.g. Racking) is fed by the
|
||||
# qty_at_step seed, not an incoming move, so it never auto-finished
|
||||
# when all its parts were sent forward. Checking for a real
|
||||
# OUTGOING move covers the seeded first stage correctly.
|
||||
if not self.move_ids.filtered(
|
||||
lambda m: m.to_step_id != self and (m.qty_moved or 0) > 0):
|
||||
return False
|
||||
try:
|
||||
self.button_finish()
|
||||
return True
|
||||
@@ -747,6 +885,16 @@ class FpJobStep(models.Model):
|
||||
Prompt = self.env['fusion.plating.process.node.input']
|
||||
if not node:
|
||||
return Prompt
|
||||
# Master switch (Sub 12d): when the recipe node opts OUT of
|
||||
# measurement collection, the Record-Inputs wizard returns ZERO
|
||||
# rows (fp_job_step_input_wizard.default_get). The finish gate MUST
|
||||
# agree — otherwise required prompts are demanded with no way to
|
||||
# enter them and the step is permanently stuck (bake nodes with
|
||||
# collect_measurements=False but required prompts — WO-30098 + 63
|
||||
# others on entech). Honour the switch here so gate <=> wizard.
|
||||
if ('collect_measurements' in node._fields
|
||||
and not node.collect_measurements):
|
||||
return Prompt
|
||||
prompts = node.input_ids
|
||||
if 'kind' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
||||
|
||||
113
fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py
Normal file
113
fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Display helpers for the redesigned job stickers (Internal = Layout A,
|
||||
one per job; External = Layout B, one per box).
|
||||
|
||||
Keeps the QWeb templates thin: all field resolution, the customer
|
||||
short-code (shop-floor "secrecy cover"), em-dash/smart-quote cleanup for
|
||||
the entech wkhtmltopdf font, and the length-tiered notes font size live
|
||||
here in Python.
|
||||
"""
|
||||
from odoo import models
|
||||
|
||||
|
||||
def _clean(text):
|
||||
"""Strip the glyphs entech's wkhtmltopdf font mojibakes."""
|
||||
if not text:
|
||||
return ''
|
||||
t = str(text)
|
||||
for a, b in ((u'—', '-'), (u'–', '-'), (u'‘', "'"),
|
||||
(u'’', "'"), (u'“', '"'), (u'”', '"'),
|
||||
(u'…', '...'),
|
||||
# Degree symbols: the masculine-ordinal 'º' (U+00BA) operators
|
||||
# type for "375ºF", the real degree '°' (U+00B0), and the ring
|
||||
# '˚' ALL mojibake to "°"/"º" through this sticker's lightweight
|
||||
# html_container path (no .article UTF-8 wrapper — and adding one
|
||||
# blows up the dpi=96 mm layout). Strip to clean ASCII: "375F".
|
||||
(u'º', ''), (u'°', ''), (u'˚', '')):
|
||||
t = t.replace(a, b)
|
||||
return t.strip()
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
def _fp_sticker_shortcode(self, partner):
|
||||
"""ABC Manufacturing Inc -> 'ABC-MANU'. First 3 of word[0] + first 4
|
||||
of word[1] (alnum-only), uppercase. Single word -> first 3."""
|
||||
name = (partner.name or '') if partner else ''
|
||||
words = [''.join(c for c in w if c.isalnum()) for w in name.split()]
|
||||
words = [w for w in words if w]
|
||||
if len(words) >= 2:
|
||||
return (words[0][:3] + '-' + words[1][:4]).upper()
|
||||
if words:
|
||||
return words[0][:3].upper()
|
||||
return name or '-'
|
||||
|
||||
def _fp_note_pt(self, text):
|
||||
"""Length-tiered notes font (pt) so long instructions stay on one
|
||||
label. Mirrors the approved mockups."""
|
||||
n = len(text or '')
|
||||
if n <= 180:
|
||||
return 11.0
|
||||
if n <= 320:
|
||||
return 10.0
|
||||
if n <= 520:
|
||||
return 9.0
|
||||
return 8.5
|
||||
|
||||
def _fp_sticker_data(self):
|
||||
"""Resolved display values for the job sticker (both variants)."""
|
||||
self.ensure_one()
|
||||
job = self
|
||||
line = job.sale_order_line_ids[:1] if 'sale_order_line_ids' in job._fields \
|
||||
else job.env['sale.order.line']
|
||||
part = (('part_catalog_id' in job._fields and job.part_catalog_id)
|
||||
or (line and 'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id)
|
||||
or False)
|
||||
so = job.sale_order_id
|
||||
|
||||
rev = ''
|
||||
if part and getattr(part, 'revision', False):
|
||||
rev = (part.revision or '').strip()
|
||||
if rev.lower().startswith('rev '):
|
||||
rev = rev[4:].strip()
|
||||
|
||||
due = job.date_deadline or (so and so.commitment_date) or False
|
||||
due_s = due.strftime('%b %d %Y') if due else ''
|
||||
|
||||
thk = ''
|
||||
if line and 'x_fc_thickness_range' in line._fields and line.x_fc_thickness_range:
|
||||
thk = _clean(line.x_fc_thickness_range)
|
||||
|
||||
q = job.qty or 0
|
||||
qty = int(q) if float(q).is_integer() else q
|
||||
|
||||
return {
|
||||
'wo': job.name or '',
|
||||
'part': ((part.part_number if part and getattr(part, 'part_number', False)
|
||||
else (part.name if part else '')) or ''),
|
||||
'rev': rev,
|
||||
'customer': self._fp_sticker_shortcode(job.partner_id),
|
||||
'customer_full': job.partner_id.name or '',
|
||||
'po': (so and getattr(so, 'x_fc_po_number', False)) or '',
|
||||
'qty': qty,
|
||||
'due': due_s,
|
||||
'thk': thk,
|
||||
# Real thickness present (has a digit) — drives the prominent
|
||||
# THICKNESS banner; skips empty / 'N/A' / '-' placeholders.
|
||||
'has_thk': bool(thk and any(c.isdigit() for c in thk)),
|
||||
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
|
||||
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
|
||||
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',
|
||||
'customer_notes': _clean(line.name) if line else '',
|
||||
}
|
||||
|
||||
def _fp_sticker_boxes(self):
|
||||
"""The job's tracked boxes (External sticker prints one label each).
|
||||
Empty recordset when none yet — the template falls back to 1/1."""
|
||||
self.ensure_one()
|
||||
if self.sale_order_id and 'fp.box' in self.env:
|
||||
return self.env['fp.box'].sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)], order='box_number')
|
||||
return self.env['fp.box'] if 'fp.box' in self.env else self.browse()
|
||||
@@ -8,6 +8,7 @@
|
||||
# reaches state='done'.
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
@@ -65,3 +66,34 @@ class FpReceiving(models.Model):
|
||||
if len(jobs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
||||
return action
|
||||
|
||||
# ---- Sticker printing from the Receiving screen (2026-06-04) ----------
|
||||
# Both stickers loop the SO's boxes (one label per box). Pass a SINGLE
|
||||
# work order: the box loop is sale-order-scoped, so feeding every job
|
||||
# would reprint each box label once per job. One job → exactly one label
|
||||
# per box. Falls back to a single 1/1 label when no boxes exist yet.
|
||||
def _fp_sticker_jobs(self):
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return self.env['fp.job']
|
||||
return self.env['fp.job'].sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)], order='id', limit=1)
|
||||
|
||||
def _fp_print_sticker(self, xmlid):
|
||||
self.ensure_one()
|
||||
jobs = self._fp_sticker_jobs()
|
||||
if not jobs:
|
||||
raise UserError(_(
|
||||
'No work order exists for this receiving yet — create the '
|
||||
'Work Order before printing stickers.'))
|
||||
return self.env.ref(xmlid).report_action(jobs)
|
||||
|
||||
def action_print_external_sticker(self):
|
||||
"""Customer (external) box sticker(s) for this receiving's WO."""
|
||||
return self._fp_print_sticker(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker')
|
||||
|
||||
def action_print_internal_sticker(self):
|
||||
"""Shop (internal) box sticker(s) — same layout, internal notes."""
|
||||
return self._fp_print_sticker(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker_internal')
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Native fp.job sticker — reuses the canonical box-sticker design from
|
||||
fusion_plating_reports.report_fp_wo_sticker_inner. The visual layout
|
||||
(logo + WO# stack on the left, big QR on the right, 7-row body table
|
||||
underneath, all wrapped in a 2px border) is the one shop staff have
|
||||
been printing since the mrp.production days; we just feed it from
|
||||
fp.job fields here instead of mrp.production.
|
||||
Redesigned job stickers (thermal label, 6x4 in / 152x102 mm):
|
||||
* Internal Job Sticker — Layout A (stacked, full-width notes),
|
||||
ONE label per job. Shop copy: x_fc_internal_description notes,
|
||||
job QR (/fp/job/<id>).
|
||||
* External Job Sticker — Layout B (left rail + tall notes),
|
||||
ONE label per fp.box. Customer copy: factory logo, BOX n/N,
|
||||
per-box QR (/fp/box/<id>), customer-facing description notes.
|
||||
|
||||
Dynamic: MASK badge when masking enabled, BAKE block when bake
|
||||
instructions present, length-tiered notes font. Field resolution +
|
||||
short-code + cleanup live in fp.job._fp_sticker_data() (Python).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
@@ -25,8 +30,12 @@
|
||||
<field name="header_line" eval="False"/>
|
||||
<field name="header_spacing">0</field>
|
||||
<field name="disable_shrinking" eval="True"/>
|
||||
<!-- dpi=300 calibrated — see CLAUDE.md rule 14, 600 broke layout. -->
|
||||
<field name="dpi">300</field>
|
||||
<!-- dpi=96 (NOT 300): this label is laid out in mm (matches the
|
||||
approved Chrome-rendered mockups). At dpi=300 wkhtmltopdf shrinks
|
||||
mm content to ~96/300 of true size (CLAUDE.md rule 14). 96 maps
|
||||
mm 1:1 so it fills the page; QR/logo stay crisp (embedded at their
|
||||
own resolution, text is vector). Legacy px-based stickers keep 300. -->
|
||||
<field name="dpi">96</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_job_sticker" model="ir.actions.report">
|
||||
@@ -41,49 +50,6 @@
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
<template id="report_fp_job_sticker_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<!-- Defaults block initialises every var the inner
|
||||
reads (so `_so or ...` doesn't NameError). We
|
||||
then override the ones we have data for. -->
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<!-- Multi-line trigger: parent SO has 2+ part-bearing lines.
|
||||
Even though this job is for a single specific part (jobs
|
||||
are grouped by recipe+part+coating+thickness+SN), the
|
||||
consolidated PO sticker is the requested behaviour. -->
|
||||
<t t-set="_so_part_lines" t-value="job.sale_order_id
|
||||
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
|
||||
or job.env['sale.order.line']"/>
|
||||
<t t-set="_multi_line" t-value="len(_so_part_lines) >= 2"/>
|
||||
<!-- Pre-resolve the variables the shared inner template
|
||||
expects, sourcing them from fp.job's native fields. -->
|
||||
<t t-set="_order_id" t-value="job.name"/>
|
||||
<t t-set="_scan_id" t-value="job.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/job/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
<t t-set="_so" t-value="job.sale_order_id"/>
|
||||
<t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
|
||||
<t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
|
||||
<t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
|
||||
<t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
|
||||
<t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
|
||||
<t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<!-- The fp.job's own name (WH/JOB/00033) is already
|
||||
printed in the header as "WO #...", so suppress
|
||||
the muted "(WH/MO/...)" suffix on the PO row. -->
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Internal Job sticker — same fields as External, but the Notes
|
||||
column reads x_fc_internal_description from the first linked
|
||||
SO line (Sub 5 thickness+serial grouping means same-x_fc lines
|
||||
share a job, so first-line is representative). -->
|
||||
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
@@ -91,41 +57,202 @@
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<!-- NOT bound to the fp.job Print menu (removed from the list per
|
||||
request). Printed via the Receiving screen button
|
||||
(fp.receiving.action_print_internal_sticker). eval=False clears
|
||||
any binding a prior install left in the DB. -->
|
||||
<field name="binding_model_id" eval="False"/>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================ Shared CSS ============================ -->
|
||||
<template id="fp_job_sticker_styles">
|
||||
<style>
|
||||
@page { size: 152mm 102mm; margin: 0; }
|
||||
* { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
html, body { margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
|
||||
.label-page { width: 152mm; height: 102mm; position: relative; overflow: hidden; page-break-after: always; }
|
||||
.label { position: absolute; top: 2mm; left: 2mm; right: 2mm; bottom: 2mm; border: 0.9mm solid #000; overflow: hidden; }
|
||||
.fpt { border-collapse: collapse; width: 100%; }
|
||||
.fpt td { vertical-align: middle; }
|
||||
.lbl { font-size: 7.5pt; font-weight: bold; letter-spacing: 0.4pt; text-transform: uppercase; display: block; }
|
||||
.band { background: #000; color: #fff; }
|
||||
.pad { padding: 1mm 2.5mm; }
|
||||
.vrule { border-right: 0.5mm solid #000; }
|
||||
.rule { border-bottom: 0.6mm solid #000; }
|
||||
.badge { display: inline-block; background: #000; color: #fff; font-size: 10pt; font-weight: 900; padding: 0.6mm 2.2mm; margin-left: 1.5mm; }
|
||||
.tag { display: inline-block; background: transparent; color: #fff; border: 0.5mm solid #fff; font-size: 8pt; font-weight: bold; padding: 0.4mm 2mm; }
|
||||
.inshead { font-size: 8.5pt; font-weight: 900; letter-spacing: 0.6pt; background: #000; color: #fff; display: inline-block; padding: 0.5mm 2.5mm; }
|
||||
.instext { font-weight: bold; }
|
||||
/* Layout B rail + main */
|
||||
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
|
||||
.main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
|
||||
.r-logo { height: 9mm; line-height: 9mm; text-align: center; }
|
||||
.r-logo img { max-height: 8mm; max-width: 45mm; vertical-align: middle; }
|
||||
.r-wo { height: 13mm; background: #000; color: #fff; padding: 0; }
|
||||
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
|
||||
.r-qrflags { height: 32mm; text-align: center; }
|
||||
.qftbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.qftbl td { vertical-align: middle; text-align: center; }
|
||||
.qfqr { width: 66%; }
|
||||
<!-- QR quiet-zone crop: the barcode bakes a ~12% white border
|
||||
around the pattern. Render the image oversized in an
|
||||
overflow:hidden wrapper, offset so the wrapper clips ~10% off
|
||||
each edge (under the quiet zone, so no modules are lost) — the
|
||||
black pattern then fills the box. White label cell around the
|
||||
wrapper still provides the scan margin. CLAUDE.md rule 14. -->
|
||||
.qfwrap-qr { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
|
||||
.qfwrap-qr img { position: absolute; width: 39mm; height: 39mm; top: -3.9mm; left: -3.9mm; }
|
||||
.qftags { width: 34%; border-left: 0.5mm solid #000; }
|
||||
.qftags .badge { display: block; width: 15mm; margin: 1.4mm auto; font-size: 9.5pt; padding: 0.8mm 0; }
|
||||
.qffull { line-height: 32mm; }
|
||||
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
|
||||
.qfwrap-full img { position: absolute; width: 38.75mm; height: 38.75mm; top: -3.875mm; left: -3.875mm; }
|
||||
/* Internal (Layout A) header QR — same ~10% quiet-zone crop. Trimmed
|
||||
30mm -> 27mm so the QR-driven black band is shorter and the freed
|
||||
height flows to the NOTES block below. Still well above scan size. */
|
||||
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 27mm; height: 27mm; vertical-align: middle; }
|
||||
.qfwrap-int img { position: absolute; width: 33.75mm; height: 33.75mm; top: -3.375mm; left: -3.375mm; }
|
||||
.r-fld { padding: 0.7mm 2.2mm; }
|
||||
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.m-thk { padding: 1.8mm 2.6mm 2.4mm; }
|
||||
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
|
||||
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<!-- ===================== External body — Layout B ===================== -->
|
||||
<template id="fp_job_external_body">
|
||||
<div class="label-page"><div class="label">
|
||||
<div class="rail">
|
||||
<div class="r-logo rule">
|
||||
<img t-if="_logo" t-att-src="image_data_uri(_logo)"/>
|
||||
<span t-if="not _logo" style="font-size:11pt;font-weight:900"><t t-esc="d['customer_full'][:18]"/></span>
|
||||
</div>
|
||||
<div class="r-wo">
|
||||
<table class="wobtbl"><tr>
|
||||
<td class="vrule" style="width:52%;border-right-color:#fff">
|
||||
<span class="lbl" style="color:#fff">Work Order</span>
|
||||
<span class="bignum"><t t-esc="d['wo'].split('-')[-1].split('/')[-1]"/></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="lbl" style="color:#fff">Box</span>
|
||||
<span class="bignum"><t t-esc="_box_num"/> / <t t-esc="_box_cnt"/></span>
|
||||
</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
<div class="r-qrflags rule">
|
||||
<t t-if="d['mask'] or d['bake']">
|
||||
<table class="qftbl"><tr>
|
||||
<td class="qfqr"><span class="qfwrap-qr"><img t-att-src="_qr"/></span></td>
|
||||
<td class="qftags">
|
||||
<t t-if="d['mask']"><span class="badge">MASK</span></t>
|
||||
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
|
||||
</td>
|
||||
</tr></table>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<table class="qftbl"><tr><td><span class="qfwrap-full"><img t-att-src="_qr"/></span></td></tr></table>
|
||||
</t>
|
||||
</div>
|
||||
<div class="r-fld rule">
|
||||
<span class="lbl">Part#</span>
|
||||
<span style="font-size:11.5pt;font-weight:900"><t t-esc="d['part'] or '-'"/></span>
|
||||
<t t-if="d['rev']"><span style="font-size:8.5pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
|
||||
</div>
|
||||
<div class="r-fld rule"><span class="lbl">Customer</span><span style="font-size:11pt;font-weight:900"><t t-esc="d['customer']"/></span></div>
|
||||
<div class="rule" style="height:8.5mm"><table class="gtbl"><tr>
|
||||
<td class="vrule" style="width:55%"><span class="lbl">PO#</span><span style="font-size:9.5pt;font-weight:bold;display:block"><t t-esc="d['po'] or '-'"/></span></td>
|
||||
<td><span class="lbl">Qty</span><span style="font-size:11pt;font-weight:900;display:block"><t t-esc="d['qty']"/></span></td>
|
||||
</tr></table></div>
|
||||
<div style="height:8.5mm"><table class="gtbl"><tr>
|
||||
<td><span class="lbl">Due</span><span style="font-size:11pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
</tr></table></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<!-- Plating thickness — the team's most-watched spec. Relocated
|
||||
out of the cramped rail to a prominent full-width banner at
|
||||
the top of the main panel. -->
|
||||
<t t-if="d['has_thk']">
|
||||
<div class="m-thk rule"><span class="inshead">PLATING THICKNESS</span>
|
||||
<div style="font-size:21pt;font-weight:900;line-height:1;margin-top:1.4mm"><t t-esc="d['thk']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="d['bake']">
|
||||
<div class="m-bake rule"><span class="inshead">BAKE</span>
|
||||
<div class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="m-notes"><span class="inshead"><t t-esc="_notes_label or 'NOTES'"/></span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.25;margin-top:1.3mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal outer (per job) ===================== -->
|
||||
<!-- Internal sticker = a COPY of the External (Layout B, one per box) but
|
||||
showing the INTERNAL description instead of the customer-facing notes,
|
||||
and a "INTERNAL NOTES" header so the shop copy can't be confused with
|
||||
the customer copy. Identical rail/QR/logo/box otherwise. -->
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_so_part_lines" t-value="job.sale_order_id
|
||||
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
|
||||
or job.env['sale.order.line']"/>
|
||||
<t t-set="_multi_line" t-value="len(_so_part_lines) >= 2"/>
|
||||
<t t-set="_order_id" t-value="job.name"/>
|
||||
<t t-set="_scan_id" t-value="job.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/job/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
<t t-set="_so" t-value="job.sale_order_id"/>
|
||||
<t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
|
||||
<t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
|
||||
<t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
|
||||
<t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
|
||||
<t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
|
||||
<t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Internal override: read x_fc_internal_description from
|
||||
the first linked SO line. Multi-line PO blanks it
|
||||
since each line has its own description. -->
|
||||
<t t-set="_notes_content" t-value="'-' if _multi_line else
|
||||
((job.sale_order_line_ids[:1]
|
||||
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
|
||||
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-')"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['internal_notes']"/>
|
||||
<t t-set="_notes_label" t-value="'INTERNAL NOTES'"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
|
||||
<t t-if="boxes">
|
||||
<t t-foreach="boxes" t-as="box">
|
||||
<t t-set="_box_num" t-value="box.box_number"/>
|
||||
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="_box_num" t-value="1"/>
|
||||
<t t-set="_box_cnt" t-value="1"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================== External outer (per box) ===================== -->
|
||||
<template id="report_fp_job_sticker_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['customer_notes']"/>
|
||||
<t t-set="_notes_label" t-value="'NOTES'"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
|
||||
<t t-if="boxes">
|
||||
<t t-foreach="boxes" t-as="box">
|
||||
<t t-set="_box_num" t-value="box.box_number"/>
|
||||
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="_box_num" t-value="1"/>
|
||||
<t t-set="_box_cnt" t-value="1"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
@@ -617,7 +617,7 @@ class TestCertCreationAndGates(TransactionCase):
|
||||
'name': 'CertCust',
|
||||
'is_company': True,
|
||||
'x_fc_send_coc': True,
|
||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
||||
'x_fc_default_coc_contact_ids': [(6, 0, [cls.contact.id])],
|
||||
})
|
||||
cls.contact.parent_id = cls.partner.id
|
||||
cls.product = cls.env['product.product'].create({
|
||||
@@ -673,7 +673,7 @@ class TestCertCreationAndGates(TransactionCase):
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
||||
self.assertEqual(cert.contact_partner_ids, self.contact)
|
||||
|
||||
def test_create_cert_computes_nc_quantity(self):
|
||||
job = self._make_job(
|
||||
|
||||
@@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase):
|
||||
'certificate_type': 'nadcap_cert',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'contact_partner_id': self.partner.id,
|
||||
'contact_partner_ids': [(6, 0, [self.partner.id])],
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'TEST PROCESS',
|
||||
'certified_by_id': self.env.user.id,
|
||||
@@ -141,3 +141,48 @@ class TestRecipeCertSuppression(TransactionCase):
|
||||
cert.with_context(
|
||||
fp_skip_cert_authority_gate=True
|
||||
).action_issue()
|
||||
|
||||
# ---- Test 7: passivation recipe also suppresses the ISSUE-TIME gate ----
|
||||
def test_passivation_recipe_suppresses_thickness_issue_gate(self):
|
||||
"""A passivation recipe (requires_thickness_report=False) must drop
|
||||
the thickness-data requirement at issue time too — even for a
|
||||
strict-thickness customer. Regression: the recipe-suppression
|
||||
feature updated _resolve_required_cert_types but NOT the
|
||||
action_issue / wizard thickness gate, so passivation CoCs could
|
||||
never be issued (gate demanded Fischerscope data the process
|
||||
cannot produce)."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
self.partner.x_fc_strict_thickness_required = True
|
||||
self.recipe.requires_thickness_report = False
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-PASSIVATION',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
# Recipe suppresses thickness -> the shared gate must NOT demand it.
|
||||
self.assertFalse(cert._fp_needs_thickness_data())
|
||||
|
||||
# ---- Test 8: normal recipe still enforces thickness (control) ----
|
||||
def test_normal_recipe_keeps_thickness_issue_gate(self):
|
||||
"""Control for Test 7: a recipe that allows thickness
|
||||
(requires_thickness_report default True) still demands thickness
|
||||
data on the CoC for a thickness customer. Guards against
|
||||
over-suppression weakening real aerospace enforcement."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
# self.recipe.requires_thickness_report stays default True.
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-NORMAL',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
self.assertTrue(cert._fp_needs_thickness_data())
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='product_id']" position="after">
|
||||
<field name="part_catalog_id" string="Part"/>
|
||||
<field name="customer_spec_id" string="Specification"/>
|
||||
<field name="recipe_id" string="Process Recipe"/>
|
||||
<field name="customer_spec_id" string="Specification" invisible="1"/>
|
||||
<field name="recipe_id" string="Process Recipe" readonly="1"/>
|
||||
</xpath>
|
||||
<!-- Show qty completed alongside total so the partial-qty
|
||||
picture is visible at a glance without opening Move Log. -->
|
||||
@@ -118,7 +118,7 @@
|
||||
<xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
|
||||
<field name="step_ids" mode="list"
|
||||
context="{'form_view_ref': 'fusion_plating_jobs.view_fp_job_step_quick_look_form'}">
|
||||
<list editable="bottom"
|
||||
<list editable="bottom" create="false" delete="false"
|
||||
decoration-info="state in ('ready', 'in_progress')"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-warning="state == 'paused'"
|
||||
@@ -162,6 +162,14 @@
|
||||
title="Finish & Next" icon="fa-check-circle"
|
||||
class="btn-link o_fp_finish_btn"
|
||||
invisible="state != 'in_progress'"/>
|
||||
<!-- Reset / redo — back to Ready so the step can be
|
||||
run again (mistake, accidental skip, customer
|
||||
redo). Clears finish + sign-off stamps; keeps the
|
||||
start audit + moves. Hidden on ready/pending. -->
|
||||
<button name="button_reset" type="object"
|
||||
title="Reset step (redo)" icon="fa-undo"
|
||||
class="btn-link text-warning"
|
||||
invisible="state in ('ready', 'pending')"/>
|
||||
|
||||
<!-- Secondary actions — small icons only. Pause is
|
||||
only relevant on a running step; Record Inputs
|
||||
@@ -302,6 +310,17 @@
|
||||
<xpath expr="//group[@name='x_fc_notes']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='costs']" position="before">
|
||||
<page string="Masking Refs" name="masking_refs"
|
||||
invisible="not x_fc_masking_attachment_ids">
|
||||
<div class="text-muted mb-2">
|
||||
Masking reference image(s)/PDF(s) attached at order entry (Express).
|
||||
The operator sees these on the masking step in the workstation.
|
||||
</div>
|
||||
<field name="x_fc_masking_attachment_ids" widget="many2many_binary"
|
||||
readonly="1" nolabel="1"/>
|
||||
</page>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='costs']" position="before">
|
||||
<page string="Notes" name="notes">
|
||||
<group>
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
|
||||
<!-- Print box stickers for this receiving's work order — one
|
||||
label per tracked box (external = customer copy, internal
|
||||
= shop copy with internal notes). Shown once a WO exists. -->
|
||||
<button name="action_print_external_sticker"
|
||||
string="External Sticker" type="object" icon="fa-print"
|
||||
invisible="x_fc_fp_job_count == 0"
|
||||
help="Print the customer (external) box sticker(s) — one per box."/>
|
||||
<button name="action_print_internal_sticker"
|
||||
string="Internal Sticker" type="object" icon="fa-print"
|
||||
invisible="x_fc_fp_job_count == 0"
|
||||
help="Print the shop (internal) box sticker(s) — same layout, internal notes."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order smart button on the button_box (mirrors the
|
||||
|
||||
39
fusion_plating/fusion_plating_jobs/views/res_users_views.xml
Normal file
39
fusion_plating/fusion_plating_jobs/views/res_users_views.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Add a plating-signature pad to the user preferences dialog.
|
||||
Anchors on the existing HTML 'signature' field (email signature)
|
||||
and adds our binary image-signature right after it. The
|
||||
widget="signature" gives finger / mouse drawing + image upload.
|
||||
-->
|
||||
<record id="view_users_preferences_form_fp_signature" model="ir.ui.view">
|
||||
<field name="name">res.users.preferences.form.fp.signature</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='signature']" position="after">
|
||||
<field name="x_fc_signature_image"
|
||||
widget="signature"
|
||||
string="Plating Signature"
|
||||
options="{'full_name': 'name'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Same field on the full user form (Settings > Users) so admins
|
||||
can review or seed signatures for operators who aren't tech-
|
||||
savvy enough to do it themselves. -->
|
||||
<record id="view_users_form_fp_signature" model="ir.ui.view">
|
||||
<field name="name">res.users.form.fp.signature</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='signature']" position="after">
|
||||
<field name="x_fc_signature_image"
|
||||
widget="signature"
|
||||
string="Plating Signature"
|
||||
options="{'full_name': 'name'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -461,17 +461,18 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
|
||||
@api.depends('cert_id.certificate_type',
|
||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required')
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required',
|
||||
'cert_id.x_fc_job_id.recipe_id.requires_thickness_report')
|
||||
def _compute_needs_thickness(self):
|
||||
# Delegate to fp.certificate._fp_needs_thickness_data — the single
|
||||
# source of truth shared with the action_issue hard gate — so the
|
||||
# wizard's readiness hint and the gate can never drift. Honours
|
||||
# recipe-level thickness suppression (passivation = no thickness
|
||||
# even if the customer asked).
|
||||
for ln in self:
|
||||
cert = ln.cert_id
|
||||
partner = cert.partner_id
|
||||
ln.needs_thickness = (
|
||||
cert.certificate_type == 'thickness_report'
|
||||
or (cert.certificate_type == 'coc' and partner and (
|
||||
partner.x_fc_strict_thickness_required
|
||||
or partner.x_fc_send_thickness_report
|
||||
))
|
||||
ln.cert_id._fp_needs_thickness_data()
|
||||
if ln.cert_id else False
|
||||
)
|
||||
|
||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
"""Scheduled delivery of finished parts back to a customer.
|
||||
@@ -480,6 +485,51 @@ class FpDelivery(models.Model):
|
||||
or rec.vehicle_id.display_name
|
||||
or 'Driver'),
|
||||
)
|
||||
# Packing slip travels with the shipment — render + attach it on
|
||||
# dispatch so the driver/customer get it and it's on the record.
|
||||
self._fp_generate_packing_slip()
|
||||
|
||||
def _fp_generate_packing_slip(self, force=False):
|
||||
"""Render each delivery's packing slip and store it on
|
||||
packing_list_attachment_id so it ships with the goods.
|
||||
|
||||
Fired on dispatch (action_start_route) and as a catch-all on
|
||||
action_mark_delivered. Idempotent + best-effort: skips deliveries
|
||||
that already carry a slip (unless force=True) and never raises —
|
||||
a report glitch must not block shipping. The report action is
|
||||
resolved at runtime so this module needs no hard dependency on
|
||||
fusion_plating_reports.
|
||||
"""
|
||||
report_xmlid = (
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_delivery_portrait'
|
||||
)
|
||||
report = self.env.ref(report_xmlid, raise_if_not_found=False)
|
||||
if not report:
|
||||
return
|
||||
for rec in self:
|
||||
if 'packing_list_attachment_id' not in rec._fields:
|
||||
return
|
||||
if rec.packing_list_attachment_id and not force:
|
||||
continue
|
||||
try:
|
||||
report_model = self.env['ir.actions.report'].sudo()
|
||||
pdf_bytes, _fmt = report_model._render_qweb_pdf(
|
||||
report_xmlid, res_ids=rec.ids)
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': _('Packing Slip - %s.pdf') % rec.display_name,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(pdf_bytes),
|
||||
'mimetype': 'application/pdf',
|
||||
'res_model': rec._name,
|
||||
'res_id': rec.id,
|
||||
})
|
||||
rec.packing_list_attachment_id = att.id
|
||||
rec.message_post(
|
||||
body=_('Packing slip generated and attached.'))
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
'Packing slip render failed for delivery %s: %s',
|
||||
rec.display_name, exc)
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Block "delivered" until a Proof of Delivery exists.
|
||||
@@ -511,6 +561,9 @@ class FpDelivery(models.Model):
|
||||
# Sub 8 — box-parity warning. Non-blocking; just posts to
|
||||
# chatter so the shipping supervisor sees it on the record.
|
||||
rec._fp_check_box_parity()
|
||||
# Catch-all: ensure a slip exists even if dispatch was skipped
|
||||
# (generate-if-missing — won't overwrite the dispatch-time one).
|
||||
self._fp_generate_packing_slip()
|
||||
|
||||
def _fp_check_box_parity(self):
|
||||
"""Compare this delivery's boxes-out count to the boxes-in count
|
||||
|
||||
@@ -377,7 +377,7 @@ class FpNotificationTemplate(models.Model):
|
||||
# Packing slip — gated by customer preference (default True)
|
||||
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
|
||||
att = _render_report(
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_portrait', delivery,
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_delivery_portrait', delivery,
|
||||
)
|
||||
if att:
|
||||
ids.append(att)
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
from . import controllers
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user