Compare commits
60 Commits
feat/fusio
...
claude/fus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7c028c30 | ||
|
|
41ce3784d7 | ||
|
|
98873c4e39 | ||
|
|
28e5e7f9de | ||
|
|
c71c60350b | ||
|
|
ba1e15da07 | ||
|
|
f1bf5b214c | ||
|
|
983e576fdc | ||
|
|
7cbf4f25df | ||
|
|
e35c120af8 | ||
|
|
e34892f5c0 | ||
|
|
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):
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,869 @@
|
||||
# WO Grouping by Recipe + Combined Multi-Part Certificate — 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:** Group sale-order plating lines into one work order (`fp.job`) per distinct plating process, and make the Certificate of Conformance multi-part so a combined WO certifies every part truthfully.
|
||||
|
||||
**Architecture:** Spec → [docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md](../specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md). Lines whose resolved recipes share an identical *step structure* (and identical masking/bake toggles) collapse onto one `fp.job`. A new `fp.certificate.part` child model holds one row per SO line; `_fp_create_certificates` fills it; the CoC report loops it. The cert multi-part support lands **before** the grouping switch so flipping the grouping is never a compliance regression.
|
||||
|
||||
**Tech Stack:** Odoo 19 (Python ORM, QWeb PDF reports), modules `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`.
|
||||
|
||||
---
|
||||
|
||||
## Testing model (read this first — the env is unusual)
|
||||
|
||||
These modules **cannot install on the local Community box** (`fusion_plating` needs Enterprise deps; `installed=0` on `modsdev`). So:
|
||||
|
||||
- **Local per-task gate (always runnable):**
|
||||
- Python: `docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/<path>.py`
|
||||
(Adjust the `/mnt/odoo-modules/fusion_plating` prefix if your bind mount differs; `K:\Github\Odoo-Modules` → `/mnt/odoo-modules`, and the plating modules live under its `fusion_plating/` subdir.)
|
||||
- XML: `docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/<path>.xml'); print('XML OK')"`
|
||||
- **Odoo unit tests** (TransactionCase, committed as real artifacts): run on an **Enterprise env where `fusion_plating` is installed** — `odoo-trial` (VM 316) if present, otherwise a throwaway **entech clone** (do NOT run `--test-enable -u` against prod `admin`). Command shape:
|
||||
```
|
||||
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
|
||||
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
|
||||
```
|
||||
- **Live read-only smoke (safe on entech prod):** re-run the recipe-signature audit (Task 8) to confirm SO-30092/30083/30079/30071 collapse to one group each. Read-only — no writes.
|
||||
- **Write-path smoke (clone / odoo-trial only):** create a test SO with same-structure lines, confirm, check one WO + one multi-part cert + render the CoC PDF.
|
||||
|
||||
Every "run the test" step below shows the command; if the Enterprise test env is not yet available, write + commit the test and run the suite at the Task 8 verification gate.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Module | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `fusion_plating_certificates/models/fp_certificate_part.py` | certificates | NEW — one row per part on a cert. |
|
||||
| `fusion_plating_certificates/models/fp_certificate.py` | certificates | ADD `part_line_ids` O2M. |
|
||||
| `fusion_plating_certificates/models/__init__.py` | certificates | import new model. |
|
||||
| `fusion_plating_certificates/security/ir.model.access.csv` | certificates | ACL for `fp.certificate.part`. |
|
||||
| `fusion_plating_certificates/views/fp_certificate_views.xml` | certificates | "Parts" notebook page. |
|
||||
| `fusion_plating_certificates/__manifest__.py` | certificates | version bump. |
|
||||
| `fusion_plating_jobs/models/fp_job.py` | jobs | requirement union + part-line build in `_fp_create_certificates`. |
|
||||
| `fusion_plating_jobs/models/sale_order.py` | jobs | grouping signature + key (the switch). |
|
||||
| `fusion_plating_jobs/report/report_fp_job_traveller.xml` | jobs | Item Information loops all parts. |
|
||||
| `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py` | jobs | backfill one part-line per existing cert. |
|
||||
| `fusion_plating_jobs/__manifest__.py` | jobs | version bump. |
|
||||
| `fusion_plating_jobs/tests/test_wo_recipe_grouping.py` | jobs | NEW — signature + grouping tests. |
|
||||
| `fusion_plating_jobs/tests/test_combined_cert_creation.py` | jobs | NEW — multi-part cert creation tests. |
|
||||
| `fusion_plating_reports/report/report_coc.xml` | reports | parts-table loop. |
|
||||
| `fusion_plating_reports/__manifest__.py` | reports | version bump. |
|
||||
|
||||
> **Migration location note:** the spec listed the backfill under `fusion_plating_certificates`. It is **moved to `fusion_plating_jobs`** here because the backfill reads `x_fc_job_id` (a jobs-module field) and runs cert helpers — both guaranteed present only after jobs loads (jobs depends on certificates). The `fp.certificate.part` table is created by the certificates upgrade, which Odoo runs first.
|
||||
|
||||
**Build order:** cert model → cert form → cert creation → CoC report → traveller → **grouping switch (last)** → migration + verify. This way the multi-part cert is ready before any WO ever carries multiple parts.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `fp.certificate.part` model + `part_line_ids` + ACL
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating_certificates/models/fp_certificate_part.py`
|
||||
- Modify: `fusion_plating_certificates/models/fp_certificate.py` (add O2M near the existing `thickness_reading_ids` at line 87)
|
||||
- Modify: `fusion_plating_certificates/models/__init__.py`
|
||||
- Modify: `fusion_plating_certificates/security/ir.model.access.csv`
|
||||
|
||||
- [ ] **Step 1: Create the model**
|
||||
|
||||
```python
|
||||
# fusion_plating_certificates/models/fp_certificate_part.py
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# One row per part on a Certificate of Conformance. A work order can
|
||||
# cover several parts that share the same plating process (see
|
||||
# fusion_plating_jobs sale_order._fp_line_group_key); the combined CoC
|
||||
# lists each part with its own identity + spec + quantities.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpCertificatePart(models.Model):
|
||||
_name = 'fp.certificate.part'
|
||||
_description = 'Certificate Part Line'
|
||||
_order = 'certificate_id, sequence, id'
|
||||
|
||||
certificate_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
sale_order_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Source SO Line',
|
||||
help='The order line this part row was built from (traceability).')
|
||||
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
|
||||
part_number = fields.Char(string='Part Number') # snapshot
|
||||
part_name = fields.Char(string='Part Name') # snapshot
|
||||
description = fields.Char(string='Description') # customer-facing snapshot
|
||||
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
|
||||
customer_spec_id = fields.Many2one(
|
||||
'fusion.plating.customer.spec', string='Customer Spec')
|
||||
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
|
||||
quantity_shipped = fields.Integer(string='Qty Shipped')
|
||||
nc_quantity = fields.Integer(string='NC Qty')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the import**
|
||||
|
||||
In `fusion_plating_certificates/models/__init__.py`, add (alphabetical / near the other cert imports):
|
||||
|
||||
```python
|
||||
from . import fp_certificate_part
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the O2M on `fp.certificate`**
|
||||
|
||||
In `fusion_plating_certificates/models/fp_certificate.py`, immediately after the `thickness_reading_ids` field (line 87-89):
|
||||
|
||||
```python
|
||||
part_line_ids = fields.One2many(
|
||||
'fp.certificate.part', 'certificate_id', string='Parts',
|
||||
help='One row per part covered by this certificate. Populated at '
|
||||
'cert creation from the work order\'s sale-order lines.')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add ACL rows**
|
||||
|
||||
Append to `fusion_plating_certificates/security/ir.model.access.csv` (mirror the existing `fp.certificate` group grants):
|
||||
|
||||
```csv
|
||||
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
|
||||
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
Run:
|
||||
```
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
|
||||
```
|
||||
Expected: no output (clean).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py \
|
||||
fusion_plating/fusion_plating_certificates/models/fp_certificate.py \
|
||||
fusion_plating/fusion_plating_certificates/models/__init__.py \
|
||||
fusion_plating/fusion_plating_certificates/security/ir.model.access.csv
|
||||
git commit -m "feat(fusion_plating_certificates): add fp.certificate.part child model + ACL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: "Parts" page on the certificate form
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_certificates/views/fp_certificate_views.xml` (notebook at line 154)
|
||||
|
||||
- [ ] **Step 1: Add the Parts page as the first notebook page**
|
||||
|
||||
Insert immediately after `<notebook>` (line 154), before the existing `<page string="Thickness Readings" ...>`:
|
||||
|
||||
```xml
|
||||
<page string="Parts" name="parts">
|
||||
<field name="part_line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="part_number"/>
|
||||
<field name="part_name"/>
|
||||
<field name="description"/>
|
||||
<field name="serial"/>
|
||||
<field name="customer_spec_id"/>
|
||||
<field name="spec_reference"/>
|
||||
<field name="quantity_shipped"/>
|
||||
<field name="nc_quantity"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Static check (XML parse)**
|
||||
|
||||
Run:
|
||||
```
|
||||
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml'); print('XML OK')"
|
||||
```
|
||||
Expected: `XML OK`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
|
||||
git commit -m "feat(fusion_plating_certificates): Parts page on certificate form"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `_fp_create_certificates` fills part-lines + requirement union
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_jobs/models/fp_job.py` (`_resolve_required_cert_types` ~line 611; `_fp_create_certificates` build of `vals` before `Cert.create(vals)` at line 2784)
|
||||
- Test: `fusion_plating_jobs/tests/test_combined_cert_creation.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# fusion_plating_jobs/tests/test_combined_cert_creation.py
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestCombinedCertCreation(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'CertCust',
|
||||
'x_fc_send_coc': True, # drives the coc requirement
|
||||
})
|
||||
self.product = self.env['product.product'].create({'name': 'W'})
|
||||
self.part_a = self.env['fp.part.catalog'].create({
|
||||
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
|
||||
self.part_b = self.env['fp.part.catalog'].create({
|
||||
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
|
||||
self.so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
|
||||
'x_fc_part_catalog_id': self.part_a.id}),
|
||||
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
|
||||
'x_fc_part_catalog_id': self.part_b.id}),
|
||||
],
|
||||
})
|
||||
|
||||
def test_combined_cert_has_one_line_per_so_line(self):
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 5.0,
|
||||
'sale_order_id': self.so.id,
|
||||
'part_catalog_id': self.part_a.id,
|
||||
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
|
||||
})
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
|
||||
self.assertEqual(len(cert), 1, 'one combined CoC')
|
||||
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
|
||||
self.assertEqual(
|
||||
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
|
||||
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
|
||||
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it (Enterprise test env) — expect FAIL**
|
||||
|
||||
Run:
|
||||
```
|
||||
odoo -d <enterprise_test_db> --test-enable \
|
||||
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
|
||||
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
|
||||
```
|
||||
Expected: FAIL — `cert.part_line_ids` is empty (creation doesn't fill it yet).
|
||||
|
||||
- [ ] **Step 3: Add helper methods on `fp.job`**
|
||||
|
||||
Add near `_fp_create_certificates` in `fusion_plating_jobs/models/fp_job.py`:
|
||||
|
||||
```python
|
||||
def _fp_cert_source_lines(self):
|
||||
"""Plating SO lines this job covers (one cert part-line each)."""
|
||||
self.ensure_one()
|
||||
lines = self.sale_order_line_ids
|
||||
if not lines and self.sale_order_id:
|
||||
lines = self.sale_order_id.order_line
|
||||
return lines.filtered(
|
||||
lambda l: not l.display_type
|
||||
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
|
||||
|
||||
def _fp_format_spec_ref(self, spec):
|
||||
"""Format 'CODE Rev X' from a customer spec (or '')."""
|
||||
if not spec:
|
||||
return ''
|
||||
ref = spec.code or ''
|
||||
if 'revision' in spec._fields and spec.revision:
|
||||
ref = (f'{ref} Rev {spec.revision}' if ref
|
||||
else f'Rev {spec.revision}')
|
||||
return ref
|
||||
|
||||
def _fp_build_cert_part_commands(self):
|
||||
"""O2M create commands for fp.certificate.part — one per line."""
|
||||
self.ensure_one()
|
||||
cmds, seq = [], 10
|
||||
for sol in self._fp_cert_source_lines():
|
||||
part = sol.x_fc_part_catalog_id
|
||||
spec = (sol.x_fc_customer_spec_id
|
||||
if 'x_fc_customer_spec_id' in sol._fields else False)
|
||||
serials = ''
|
||||
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
|
||||
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
|
||||
desc = (sol.fp_customer_description()
|
||||
if hasattr(sol, 'fp_customer_description')
|
||||
else (sol.name or ''))
|
||||
cmds.append((0, 0, {
|
||||
'sequence': seq,
|
||||
'sale_order_line_id': sol.id,
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': (part.part_number if part else '') or '',
|
||||
'part_name': (part.name if part else '') or '',
|
||||
'description': desc or '',
|
||||
'serial': serials,
|
||||
'customer_spec_id': spec.id if spec else False,
|
||||
'spec_reference': self._fp_format_spec_ref(spec),
|
||||
'quantity_shipped': int(sol.product_uom_qty or 0),
|
||||
'nc_quantity': 0,
|
||||
}))
|
||||
seq += 10
|
||||
return cmds
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fill `part_line_ids` in `_fp_create_certificates`**
|
||||
|
||||
In `_fp_create_certificates`, immediately before `cert = Cert.create(vals)` (line 2784), add:
|
||||
|
||||
```python
|
||||
if 'part_line_ids' in Cert._fields:
|
||||
part_cmds = self._fp_build_cert_part_commands()
|
||||
if part_cmds:
|
||||
vals['part_line_ids'] = part_cmds
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Requirement union over all parts**
|
||||
|
||||
In `_resolve_required_cert_types` (Step 1, ~line 611-642), replace the single-part read with a union across all parts on the job. Change the Step-1 block so `wanted` is the union of each line's part-level requirement (falling back to the partner inherit set computed once):
|
||||
|
||||
```python
|
||||
# ---- Step 1 — partner + part baseline (union across all parts) ----
|
||||
def _partner_inherit_set():
|
||||
s = set()
|
||||
p = self.partner_id
|
||||
if p:
|
||||
if p.x_fc_send_coc:
|
||||
s.add('coc')
|
||||
if p.x_fc_send_thickness_report:
|
||||
s.add('thickness_report')
|
||||
if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
|
||||
s.add('nadcap_cert')
|
||||
if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
|
||||
s.add('mill_test')
|
||||
if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
|
||||
s.add('customer_specific')
|
||||
return s
|
||||
|
||||
def _explicit_set(req):
|
||||
return {
|
||||
'none': set(), 'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
}.get(req, {'coc'})
|
||||
|
||||
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
|
||||
if not parts and self.part_catalog_id:
|
||||
parts = self.part_catalog_id
|
||||
wanted = set()
|
||||
inherit = None
|
||||
for part in (parts or [False]):
|
||||
req = (part.certificate_requirement
|
||||
if part and 'certificate_requirement' in part._fields
|
||||
else 'inherit') or 'inherit'
|
||||
if req == 'inherit':
|
||||
if inherit is None:
|
||||
inherit = _partner_inherit_set()
|
||||
wanted |= inherit
|
||||
else:
|
||||
wanted |= _explicit_set(req)
|
||||
```
|
||||
|
||||
Leave Step 2 (recipe suppression) and Step 3 (CoC/thickness bundling) unchanged — they already operate on `wanted`.
|
||||
|
||||
- [ ] **Step 6: Run the test — expect PASS**
|
||||
|
||||
Run:
|
||||
```
|
||||
odoo -d <enterprise_test_db> --test-enable \
|
||||
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
|
||||
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
|
||||
```
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Static check**
|
||||
|
||||
Run:
|
||||
```
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/fp_job.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
|
||||
```
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
|
||||
fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
|
||||
git commit -m "feat(fusion_plating_jobs): multi-part cert creation + requirement union"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CoC report renders the parts table as a loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_reports/report/report_coc.xml` (tbody at lines 297-321)
|
||||
|
||||
- [ ] **Step 1: Replace the single hard-coded row with a loop + fallback**
|
||||
|
||||
Replace the `<tbody>...</tbody>` block (lines 297-322) with:
|
||||
|
||||
```xml
|
||||
<tbody>
|
||||
<t t-foreach="doc.part_line_ids" t-as="pl">
|
||||
<tr>
|
||||
<td class="text-center" style="line-height: 1.3;">
|
||||
<div><t t-esc="pl.part_number or '-'"/></div>
|
||||
<div><t t-esc="pl.part_name or '-'"/></div>
|
||||
<div><t t-esc="pl.serial or '-'"/></div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-esc="pl.description or doc.process_description or ''"/>
|
||||
<t t-if="pl.spec_reference">
|
||||
<br/><em t-esc="pl.spec_reference"/>
|
||||
</t>
|
||||
</td>
|
||||
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
|
||||
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
|
||||
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
|
||||
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr t-if="not doc.part_line_ids">
|
||||
<td class="text-center" style="line-height: 1.3;">
|
||||
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
|
||||
<div><t t-esc="pid[0] or '-'"/></div>
|
||||
<div><t t-esc="pid[1] or '-'"/></div>
|
||||
<div><t t-esc="pid[2] or '-'"/></div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
|
||||
<t t-esc="cust_desc or doc.process_description or ''"/>
|
||||
<t t-if="doc.spec_reference">
|
||||
<br/><em t-esc="doc.spec_reference"/>
|
||||
</t>
|
||||
</td>
|
||||
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
|
||||
<td class="text-center"><t t-esc="doc.quantity_shipped or 0"/></td>
|
||||
<td class="text-center"><t t-esc="doc.nc_quantity or 0"/></td>
|
||||
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
```
|
||||
|
||||
> Keep `page-break-inside: avoid` on the parent table (line 271-272) unchanged. Each part row is short; the table-level rule already prevents mid-row splits for the typical 1-4 part case.
|
||||
|
||||
- [ ] **Step 2: Static check (XML parse)**
|
||||
|
||||
Run:
|
||||
```
|
||||
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_reports/report/report_coc.xml'); print('XML OK')"
|
||||
```
|
||||
Expected: `XML OK`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_reports/report/report_coc.xml
|
||||
git commit -m "feat(fusion_plating_reports): CoC parts table loops part_line_ids"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Traveller lists every part in the batch
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_jobs/report/report_fp_job_traveller.xml` (Item Information block, ~lines 116-160)
|
||||
|
||||
- [ ] **Step 1: Loop the plating lines in the Item Information cell**
|
||||
|
||||
The Item Information `<td>` currently renders `job.part_catalog_id` once (singular). Wrap the per-part rows in a loop over the job's plating lines, falling back to the singular part when no lines are linked. Replace the singular part-number / revision / material / name reads (lines ~127-157) with:
|
||||
|
||||
```xml
|
||||
<t t-set="trav_lines"
|
||||
t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else job.browse([])"/>
|
||||
<t t-if="not trav_lines and 'part_catalog_id' in job._fields and job.part_catalog_id">
|
||||
<t t-set="trav_parts" t-value="[job.part_catalog_id]"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id')"/>
|
||||
</t>
|
||||
<t t-foreach="trav_parts" t-as="tp">
|
||||
<div style="margin-bottom: 2px;">
|
||||
<strong t-esc="tp.part_number or '—'"/>
|
||||
<t t-if="'revision' in tp._fields and tp.revision">
|
||||
<span> Rev <t t-esc="tp.revision"/></span>
|
||||
</t>
|
||||
<t t-if="'base_material' in tp._fields and tp.base_material">
|
||||
<span> · <t t-esc="tp.base_material"/></span>
|
||||
</t>
|
||||
<span> · <t t-esc="tp.name or '—'"/></span>
|
||||
</div>
|
||||
</t>
|
||||
```
|
||||
|
||||
> This preserves the existing field reads (`part_number`, `revision`, `base_material`, `name`) but emits one line per part. The routing/process table below (one shared recipe) is unchanged. Verify the surrounding `<td>`/column structure still balances after the edit — keep the edit inside the existing Item Information cell.
|
||||
|
||||
- [ ] **Step 2: Static check (XML parse)**
|
||||
|
||||
Run:
|
||||
```
|
||||
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml'); print('XML OK')"
|
||||
```
|
||||
Expected: `XML OK`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml
|
||||
git commit -m "feat(fusion_plating_jobs): traveller lists all parts in the batch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Grouping by recipe structural signature (the switch)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_jobs/models/sale_order.py` (`_fp_auto_create_job` groups block, lines 439-470)
|
||||
- Test: `fusion_plating_jobs/tests/test_wo_recipe_grouping.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# fusion_plating_jobs/tests/test_wo_recipe_grouping.py
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestWoRecipeGrouping(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.SO = self.env['sale.order']
|
||||
self.Node = self.env['fusion.plating.process.node']
|
||||
|
||||
def _recipe(self, name, step_names):
|
||||
root = self.Node.create({'name': name, 'node_type': 'recipe'})
|
||||
seq = 10
|
||||
for sn in step_names:
|
||||
self.Node.create({
|
||||
'name': sn, 'node_type': 'step',
|
||||
'parent_id': root.id, 'sequence': seq})
|
||||
seq += 10
|
||||
return root
|
||||
|
||||
def test_identical_structure_same_signature(self):
|
||||
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
self.assertEqual(
|
||||
self.SO._fp_recipe_signature(r1),
|
||||
self.SO._fp_recipe_signature(r2),
|
||||
'clones with identical steps share a signature')
|
||||
|
||||
def test_different_structure_different_signature(self):
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
|
||||
self.assertNotEqual(
|
||||
self.SO._fp_recipe_signature(r1),
|
||||
self.SO._fp_recipe_signature(r2))
|
||||
|
||||
def test_so_groups_same_structure_into_one_wo(self):
|
||||
partner = self.env['res.partner'].create({'name': 'G'})
|
||||
product = self.env['product.product'].create({'name': 'P'})
|
||||
pa = self.env['fp.part.catalog'].create({
|
||||
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
|
||||
pb = self.env['fp.part.catalog'].create({
|
||||
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
|
||||
pc = self.env['fp.part.catalog'].create({
|
||||
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
|
||||
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
|
||||
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pa.id,
|
||||
'x_fc_process_variant_id': r1.id}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pb.id,
|
||||
'x_fc_process_variant_id': r2.id}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pc.id,
|
||||
'x_fc_process_variant_id': r3.id}),
|
||||
],
|
||||
})
|
||||
so._fp_auto_create_job()
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
||||
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
|
||||
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
|
||||
self.assertEqual(sizes, [1, 2])
|
||||
|
||||
def test_masking_toggle_splits_same_structure(self):
|
||||
partner = self.env['res.partner'].create({'name': 'M'})
|
||||
product = self.env['product.product'].create({'name': 'P'})
|
||||
pa = self.env['fp.part.catalog'].create({
|
||||
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
|
||||
pb = self.env['fp.part.catalog'].create({
|
||||
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
|
||||
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pa.id,
|
||||
'x_fc_process_variant_id': r1.id,
|
||||
'x_fc_masking_enabled': True}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pb.id,
|
||||
'x_fc_process_variant_id': r2.id,
|
||||
'x_fc_masking_enabled': False}),
|
||||
],
|
||||
})
|
||||
so._fp_auto_create_job()
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
||||
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run them — expect FAIL**
|
||||
|
||||
Run:
|
||||
```
|
||||
odoo -d <enterprise_test_db> --test-enable \
|
||||
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
|
||||
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
|
||||
```
|
||||
Expected: FAIL — `_fp_recipe_signature` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the signature helpers on `sale.order`**
|
||||
|
||||
In `fusion_plating_jobs/models/sale_order.py`, add these methods (near `_fp_resolve_recipe_for_line`):
|
||||
|
||||
```python
|
||||
def _fp_recipe_signature(self, recipe):
|
||||
"""Hashable structural signature of a recipe's step tree.
|
||||
|
||||
Two recipes with the same signature have identical processing
|
||||
steps and can share one work order. Excludes the recipe ROOT
|
||||
(its name carries the per-part ' — <part#>' suffix) and all
|
||||
numeric targets — those are per-part attestation data on the
|
||||
cert, not a batch splitter. Returns None for a missing recipe.
|
||||
"""
|
||||
if not recipe:
|
||||
return None
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
kids = Node.search(
|
||||
[('id', 'child_of', recipe.id),
|
||||
('node_type', 'in', ('sub_process', 'operation', 'step'))],
|
||||
order='parent_path, sequence')
|
||||
return tuple(
|
||||
(k.node_type,
|
||||
(k.kind_id.code if k.kind_id else '') or '',
|
||||
(k.name or '').strip().lower())
|
||||
for k in kids)
|
||||
|
||||
def _fp_line_express_signature(self, line):
|
||||
"""Per-line Express toggles that change which steps exist:
|
||||
masking on/off and bake present/absent. Lines differing here
|
||||
must not merge (the shared WO would silently drop one part's
|
||||
masking or bake step). Free-text bake instructions are NOT in
|
||||
the signature — both-present lines merge and the bake step
|
||||
carries the last applied line's text (known Phase-1 limit)."""
|
||||
F = line._fields
|
||||
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
|
||||
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
|
||||
if 'x_fc_bake_instructions' in F else False
|
||||
return (masking, has_bake)
|
||||
|
||||
def _fp_line_group_key(self, line):
|
||||
"""WO grouping key. Lines with the same key ride one work order."""
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
if not recipe:
|
||||
return ('no_recipe', line.id) # never merges
|
||||
return ('recipe',
|
||||
self._fp_recipe_signature(recipe),
|
||||
self._fp_line_express_signature(line))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace the grouping loop**
|
||||
|
||||
In `_fp_auto_create_job`, replace the `groups`-building block (lines 445-470, the `unrecipe_idx`/5-tuple-key logic) with:
|
||||
|
||||
```python
|
||||
# Group by recipe structural signature (+ per-line masking/bake
|
||||
# toggles). Lines whose recipes have identical steps collapse onto
|
||||
# one WO; no-recipe lines stay separate. See spec
|
||||
# 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
|
||||
groups = {}
|
||||
for line in plating_lines:
|
||||
key = self._fp_line_group_key(line)
|
||||
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
||||
```
|
||||
|
||||
Everything after (the `ordered_keys = sorted(...)` block at line 473 onward) is unchanged — it still derives `n_groups`, names WOs `WO-<parent>` / `WO-<parent>-NN`, and builds one job per group carrying `sale_order_line_ids`.
|
||||
|
||||
- [ ] **Step 5: Run the tests — expect PASS**
|
||||
|
||||
Run:
|
||||
```
|
||||
odoo -d <enterprise_test_db> --test-enable \
|
||||
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
|
||||
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
|
||||
```
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 6: Static check**
|
||||
|
||||
Run:
|
||||
```
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/sale_order.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
|
||||
```
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_jobs/models/sale_order.py \
|
||||
fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
|
||||
git commit -m "feat(fusion_plating_jobs): group WOs by recipe step structure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Migration backfill + version bumps
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py`
|
||||
- Modify: `fusion_plating_jobs/__manifest__.py` (`19.0.12.1.6` → `19.0.12.2.0`)
|
||||
- Modify: `fusion_plating_certificates/__manifest__.py` (`19.0.9.3.0` → `19.0.10.0.0`)
|
||||
- Modify: `fusion_plating_reports/__manifest__.py` (`19.0.11.34.0` → `19.0.11.35.0`)
|
||||
|
||||
- [ ] **Step 1: Write the backfill migration**
|
||||
|
||||
```python
|
||||
# fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
|
||||
# -*- coding: utf-8 -*-
|
||||
# Backfill one fp.certificate.part per existing certificate from its
|
||||
# legacy singular fields, so pre-existing certs render identically under
|
||||
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
|
||||
# because it reads x_fc_job_id, a jobs-module field; the part-line table
|
||||
# itself is created by the certificates upgrade, which runs first.
|
||||
import logging
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
if 'fp.certificate.part' not in env:
|
||||
return
|
||||
certs = env['fp.certificate'].search([])
|
||||
made = 0
|
||||
for cert in certs:
|
||||
if cert.part_line_ids:
|
||||
continue
|
||||
try:
|
||||
pid = cert._fp_resolve_part_identity() # (number, name, serials)
|
||||
except Exception:
|
||||
pid = ('', '', '')
|
||||
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
|
||||
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
|
||||
try:
|
||||
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
|
||||
except Exception:
|
||||
desc = cert.process_description or ''
|
||||
env['fp.certificate.part'].create({
|
||||
'certificate_id': cert.id, 'sequence': 10,
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': cert.part_number or (pid[0] or ''),
|
||||
'part_name': pid[1] or '',
|
||||
'description': desc,
|
||||
'serial': pid[2] or '',
|
||||
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
|
||||
'spec_reference': cert.spec_reference or '',
|
||||
'quantity_shipped': cert.quantity_shipped or 0,
|
||||
'nc_quantity': cert.nc_quantity or 0,
|
||||
})
|
||||
made += 1
|
||||
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Bump versions**
|
||||
|
||||
`fusion_plating_jobs/__manifest__.py`: `'version': '19.0.12.1.6',` → `'version': '19.0.12.2.0',`
|
||||
`fusion_plating_certificates/__manifest__.py`: `'version': '19.0.9.3.0',` → `'version': '19.0.10.0.0',`
|
||||
`fusion_plating_reports/__manifest__.py`: `'version': '19.0.11.34.0',` → `'version': '19.0.11.35.0',`
|
||||
|
||||
- [ ] **Step 3: Static check**
|
||||
|
||||
Run:
|
||||
```
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
|
||||
```
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py \
|
||||
fusion_plating/fusion_plating_jobs/__manifest__.py \
|
||||
fusion_plating/fusion_plating_certificates/__manifest__.py \
|
||||
fusion_plating/fusion_plating_reports/__manifest__.py
|
||||
git commit -m "feat(fusion_plating): cert backfill migration + version bumps"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Verification (Enterprise env + read-only entech smoke)
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Full suite on the Enterprise test env**
|
||||
|
||||
Run:
|
||||
```
|
||||
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
|
||||
-u fusion_plating_jobs,fusion_plating_certificates,fusion_plating_reports \
|
||||
--stop-after-init --http-port=0 --gevent-port=0
|
||||
```
|
||||
Expected: exit 0; the new grouping + cert tests pass; no regressions in existing `fusion_plating_jobs` tests.
|
||||
|
||||
- [ ] **Step 2: Read-only signature re-run on entech (prod-safe)**
|
||||
|
||||
Confirm the four real orders collapse. In `odoo shell -d admin` on entech (read-only — no commit):
|
||||
|
||||
```python
|
||||
SO = env['sale.order']
|
||||
for name in ('SO-30092', 'SO-30083', 'SO-30079', 'SO-30071'):
|
||||
so = SO.search([('name', '=', name)], limit=1)
|
||||
if not so:
|
||||
continue
|
||||
lines = so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
|
||||
keys = {SO._fp_line_group_key(l) for l in lines}
|
||||
print(name, 'lines=%d' % len(lines), 'groups=%d' % len(keys))
|
||||
# Expect: each prints groups=1
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write-path smoke (clone / odoo-trial — NOT prod)**
|
||||
|
||||
On a non-prod Enterprise DB: create an SO with 3 lines (2 sharing a structurally-identical recipe, 1 different) for a partner with `x_fc_send_coc=True`; confirm it; verify (a) **2** `fp.job` records, (b) the merged job has 2 `sale_order_line_ids`, (c) closing the merged job produces **one** CoC with **2** `part_line_ids`, (d) the rendered CoC PDF shows 2 part rows, (e) a migrated legacy single-part cert still renders one row.
|
||||
|
||||
- [ ] **Step 4: Mark plan complete**
|
||||
|
||||
All boxes checked, suite green, entech smoke shows `groups=1` for the four orders → ready to deploy (entech upgrade of the three modules, per the standard deploy recipe in CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Self-review (completed by plan author)
|
||||
|
||||
- **Spec coverage:** grouping signature (Task 6) ✓; combined cert + per-part lines (Tasks 1-3) ✓; CoC report loop (Task 4) ✓; traveller (Task 5) ✓; migration backfill (Task 7) ✓; requirement union (Task 3) ✓; locked decisions (NC=0 editable, union lists all parts, masking/bake split) encoded in Tasks 3 & 6 ✓. Phase 2 (per-part thickness, per-part stickers) intentionally out of scope.
|
||||
- **Placeholder scan:** no TBD/TODO; every code step shows complete code; `<enterprise_test_db>` is an explicit env parameter (documented in the Testing model), not a code placeholder.
|
||||
- **Type/name consistency:** `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key` (Task 6) match their uses; `fp.certificate.part` fields (Task 1) match the part-line build (Task 3), the report (Task 4), and the migration (Task 7); `part_line_ids` used consistently across Tasks 1-4 & 7.
|
||||
- **Known limitation (documented in code):** two same-structure lines that both have bake instructions but different text merge; the shared bake step carries the last applied line's text. Acceptable for Phase 1.
|
||||
@@ -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,425 @@
|
||||
# WO Grouping by Recipe + Combined Multi-Part Certificate
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Module(s):** `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`
|
||||
**Author:** Gurpreet (Nexa Systems Inc.)
|
||||
**Status:** Approved — ready for implementation plan
|
||||
|
||||
## Summary
|
||||
|
||||
Today a confirmed sale order with N plating lines creates N work orders
|
||||
(`fp.job` / "WO-NNN"), even when every line runs the same plating
|
||||
process. The shop wants **one work order per recipe** — different parts
|
||||
that go through the same process should ride one traveller and one
|
||||
physical batch, splitting into separate WOs **only when the process
|
||||
actually differs**.
|
||||
|
||||
The blocker is the **Certificate of Conformance**: a `fp.job` carries a
|
||||
single `part_catalog_id` / `customer_spec_id`, and the CoC PDF renders
|
||||
exactly one part row. Collapsing four parts onto one WO would certify
|
||||
only the first and silently ship the other three uncertified — the exact
|
||||
"silent mis-attestation" the 2026-05-13 sticker spec was built to
|
||||
prevent.
|
||||
|
||||
This spec resolves that by making the **certificate multi-part**: one
|
||||
combined CoC per WO that lists every part in a table, each with its own
|
||||
part #, spec, serial, and quantities. The grouping change and the
|
||||
multi-part cert ship together because neither is safe alone.
|
||||
|
||||
## Audit findings (live entech, db=admin, read-only, 2026-06-03)
|
||||
|
||||
Pulled the real numbers before designing — they overturned the obvious
|
||||
"group by `recipe_id`" approach.
|
||||
|
||||
| Order | Lines | WOs today | Distinct recipes | WOs after |
|
||||
|-------|-------|-----------|------------------|-----------|
|
||||
| SO-30092 | 2 | 2 | 2 (`ENP ALUM BASIC HP`) | **1** |
|
||||
| SO-30083 | 4 | 4 | 4 (`ENP-STEEL-MP-BASIC`) | **1** |
|
||||
| SO-30079 | 4 | 4 | 4 (2 parts × 2 lines) | **1** |
|
||||
| SO-30071 | 3 | 3 | 3 (`ENP-STEEL-MP-BASIC`) | **1** |
|
||||
|
||||
- 23 confirmed SOs total; 4 are multi-plating-line. 13 plating lines
|
||||
across those 4 orders collapse from **13 WOs → 4 WOs**.
|
||||
- **Root cause:** every part gets its own *clone* of a base recipe,
|
||||
renamed `<BASE> — <part#>` (the ` — <suffix>` is stamped by
|
||||
`_clone_subtree` in `fp_part_composer_controller.py`). So each line
|
||||
resolves to a *distinct* `fusion.plating.process.node` record →
|
||||
grouping by `recipe_id` merges **nothing**.
|
||||
- The clones are **byte-identical in structure** — 9 (or 11) descendant
|
||||
nodes, same `node_type` + `kind_id.code` + name in the same order.
|
||||
Verified across all 4 orders. So merging is **faithful**: every part
|
||||
follows the identical steps.
|
||||
- `process_type_id` is **empty** on all of them → not a usable signal.
|
||||
- `cloned_from_id` exists as a field but is **empty on all 13** lines →
|
||||
not usable for existing data without a backfill.
|
||||
- **13 existing `fp.certificate` rows** → migration size.
|
||||
|
||||
**Conclusion:** the only signals that work on real data are *identical
|
||||
step structure* and *shared base-name prefix*. We group by **identical
|
||||
step structure** (truthful, naming-independent, no backfill).
|
||||
|
||||
## Locked decisions (from brainstorming, 2026-06-03)
|
||||
|
||||
| Q | Decision |
|
||||
|---|----------|
|
||||
| One WO covers many parts — how do certs work? | **One combined cert** listing every part in a table. |
|
||||
| How much varies between parts in one order? | **Varies by order** → build the full per-part model (handles uniform and per-part-divergent orders). |
|
||||
| Is "same recipe" one shared record or per-part copies? | **Audited:** per-part clones, structurally identical. Group by structure, not record id. |
|
||||
| Grouping signal? | **Identical step structure** (recipe structural signature). |
|
||||
| Two recipes "the same"? | Same `node_type` + `kind_id.code` + name sequence across descendant steps. Numeric targets (thickness/temp/time) are **excluded** — they're per-part attestation data on the cert, not a batch splitter. |
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
**Goals**
|
||||
- One WO per distinct plating process; same-process parts share one WO.
|
||||
- A single combined CoC per WO listing each part's own identity + spec +
|
||||
quantities.
|
||||
- No silent loss of any part's certification when parts share a WO.
|
||||
- Per-part masking/bake differences split the WO (never silently merge).
|
||||
- Existing WOs and certs keep working unchanged; the 13 existing certs
|
||||
render identically after migration.
|
||||
|
||||
**Non-goals**
|
||||
- Re-grouping already-created WOs (only new confirmations regroup).
|
||||
- Removing the per-part recipe-cloning mechanism (root-cause fix to the
|
||||
Part Composer — separate, larger, riskier; out of scope).
|
||||
- Per-part thickness rendering, per-part box stickers, per-part issue
|
||||
gate → **Phase 2** (see below).
|
||||
- Per-physical-box serial tracking (unchanged from prior specs).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Phase 1 — compliance-safe MVP
|
||||
|
||||
#### Change 1 — Grouping by recipe structural signature
|
||||
|
||||
File: `fusion_plating_jobs/models/sale_order.py`, method
|
||||
`_fp_auto_create_job` (the `groups` block around line 439-470).
|
||||
|
||||
Replace the 5-tuple key `(recipe, part, spec, thickness, serial)` with a
|
||||
**structural signature** key. New helpers on `sale.order`:
|
||||
|
||||
```python
|
||||
def _fp_recipe_signature(self, recipe):
|
||||
"""Hashable structural signature of a recipe's step tree.
|
||||
|
||||
Two recipes with the same signature have identical processing
|
||||
steps and can share one work order. Excludes the recipe ROOT name
|
||||
(carries the per-part ' — <part#>' suffix) and all numeric targets
|
||||
(thickness/temp/time/voltage) — those are per-part attestation
|
||||
data captured on the cert, not a reason to split the batch.
|
||||
Returns None for a missing recipe.
|
||||
"""
|
||||
if not recipe:
|
||||
return None
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
kids = Node.search(
|
||||
[('id', 'child_of', recipe.id),
|
||||
('node_type', 'in', ('sub_process', 'operation', 'step'))],
|
||||
order='parent_path, sequence')
|
||||
return tuple(
|
||||
(k.node_type,
|
||||
(k.kind_id.code if k.kind_id else '') or '',
|
||||
(k.name or '').strip().lower())
|
||||
for k in kids)
|
||||
|
||||
def _fp_line_express_signature(self, line):
|
||||
"""Per-line Express override flags that change physical processing
|
||||
(masking on/off, bake setpoint/duration, etc.). Lines that differ
|
||||
here must NOT merge even when the recipe structure matches, or the
|
||||
shared WO would silently drop one part's masking/bake.
|
||||
|
||||
The exact field set is enumerated from sale.order.line's Express
|
||||
Orders fields at implementation time (x_fc_masking_enabled + the
|
||||
bake override fields); all reads are field-guarded.
|
||||
"""
|
||||
F = line._fields
|
||||
bits = []
|
||||
for fname in self._FP_EXPRESS_OVERRIDE_FIELDS:
|
||||
if fname in F:
|
||||
bits.append((fname, line[fname]))
|
||||
return tuple(bits)
|
||||
|
||||
def _fp_line_group_key(self, line):
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
if not recipe:
|
||||
return ('no_recipe', line.id) # never merges
|
||||
return ('recipe',
|
||||
self._fp_recipe_signature(recipe),
|
||||
self._fp_line_express_signature(line))
|
||||
```
|
||||
|
||||
The grouping loop becomes:
|
||||
|
||||
```python
|
||||
groups = {}
|
||||
for line in plating_lines:
|
||||
key = self._fp_line_group_key(line)
|
||||
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
||||
```
|
||||
|
||||
Everything downstream of `groups` is unchanged: `ordered_keys` still
|
||||
sorts by min line sequence, `n_groups` still drives single-vs-suffixed
|
||||
WO naming (`WO-<parent>` vs `WO-<parent>-NN`), and the per-group job
|
||||
create loop already sums qty, carries `sale_order_line_ids`, and copies
|
||||
SO header fields.
|
||||
|
||||
**Representative recipe:** the WO's `recipe_id` is the first line's
|
||||
recipe in the group. Because every recipe in the group is structurally
|
||||
identical, step generation (`fp.job.action_confirm` →
|
||||
`_generate_steps_from_recipe`) produces the correct steps for all parts.
|
||||
|
||||
**Job singular fields:** `part_catalog_id` / `customer_spec_id` keep
|
||||
pointing at the first line's values (display + back-compat). The
|
||||
per-part truth lives in `sale_order_line_ids` and the cert part-lines.
|
||||
|
||||
#### Change 2 — `fp.certificate.part` (new child model)
|
||||
|
||||
File: `fusion_plating_certificates/models/fp_certificate_part.py` (new).
|
||||
|
||||
```python
|
||||
class FpCertificatePart(models.Model):
|
||||
_name = 'fp.certificate.part'
|
||||
_description = 'Certificate Part Line'
|
||||
_order = 'certificate_id, sequence, id'
|
||||
|
||||
certificate_id = fields.Many2one(
|
||||
'fp.certificate', required=True, ondelete='cascade', index=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
sale_order_line_id = fields.Many2one('sale.order.line') # traceability
|
||||
part_catalog_id = fields.Many2one('fp.part.catalog')
|
||||
part_number = fields.Char() # snapshot
|
||||
part_name = fields.Char() # snapshot of catalog .name
|
||||
description = fields.Char() # customer-facing description snapshot
|
||||
serial = fields.Char() # comma-joined serial names snapshot
|
||||
customer_spec_id = fields.Many2one('fusion.plating.customer.spec')
|
||||
spec_reference = fields.Char() # snapshot 'CODE Rev X'
|
||||
quantity_shipped = fields.Integer()
|
||||
nc_quantity = fields.Integer()
|
||||
# Phase 2: thickness_reading_ids (inverse certificate_part_id)
|
||||
```
|
||||
|
||||
On `fp.certificate`:
|
||||
|
||||
```python
|
||||
part_line_ids = fields.One2many(
|
||||
'fp.certificate.part', 'certificate_id', string='Parts')
|
||||
```
|
||||
|
||||
Views: add an editable `part_line_ids` list to the certificate form
|
||||
(so the issuer can review/adjust before issuing). ACL rows for
|
||||
`fp.certificate.part` mirror `fp.certificate`'s groups (operator read +
|
||||
manager write, matching the existing cert ACL).
|
||||
|
||||
#### Change 3 — `_fp_create_certificates` fills part-lines
|
||||
|
||||
File: `fusion_plating_jobs/models/fp_job.py` (method around line 2716).
|
||||
|
||||
- **Requirement union** — `_resolve_required_cert_types` currently reads
|
||||
the *first* part's `certificate_requirement`. Walk **all** plating
|
||||
lines on the job; union each part's wanted set (part-level override
|
||||
else partner inherit). Recipe suppression + CoC/thickness bundling are
|
||||
unchanged (uniform — one recipe per WO).
|
||||
- **Cert create** — still one cert per resulting type. Cert-level fields
|
||||
(po_number, customer_job_no, process_description = base recipe name,
|
||||
certified_by_id, contact, entech_wo_number, sale_order_id, x_fc_job_id)
|
||||
unchanged. **Legacy singular fields** (part_number, spec_reference,
|
||||
quantity_shipped, nc_quantity) keep being set from the **first** line
|
||||
for back-compat.
|
||||
- **Part-lines** — build one `fp.certificate.part` per plating line on
|
||||
the job (`_fp_cert_source_lines()` = `sale_order_line_ids` filtered to
|
||||
lines with a part):
|
||||
|
||||
```python
|
||||
seq = 10
|
||||
part_cmds = []
|
||||
for sol in self._fp_cert_source_lines():
|
||||
part = sol.x_fc_part_catalog_id
|
||||
spec = sol.x_fc_customer_spec_id if 'x_fc_customer_spec_id' in sol._fields else False
|
||||
part_cmds.append((0, 0, {
|
||||
'sequence': seq,
|
||||
'sale_order_line_id': sol.id,
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': (part.part_number if part else '') or '',
|
||||
'part_name': (part.name if part else '') or '',
|
||||
'description': sol.fp_customer_description()
|
||||
if hasattr(sol, 'fp_customer_description') else (sol.name or ''),
|
||||
'serial': ', '.join(sol.x_fc_serial_ids.mapped('name'))
|
||||
if 'x_fc_serial_ids' in sol._fields else '',
|
||||
'customer_spec_id': spec.id if spec else False,
|
||||
'spec_reference': self._fp_format_spec_ref(spec),
|
||||
'quantity_shipped': int(sol.product_uom_qty or 0),
|
||||
'nc_quantity': 0,
|
||||
}))
|
||||
seq += 10
|
||||
vals['part_line_ids'] = part_cmds
|
||||
```
|
||||
|
||||
**Per-part quantities:** `quantity_shipped` defaults to the **line**
|
||||
qty (naturally per-part). `nc_quantity` defaults to **0** — scrap /
|
||||
visual rejects are tracked at job level only, not per part, so we do not
|
||||
auto-split them; the issuer edits per-part NC at issue if needed. The
|
||||
job-level NC total remains on the cert's legacy `nc_quantity` field.
|
||||
|
||||
**Idempotency:** the existing per-type idempotency guard is unchanged;
|
||||
re-running `_fp_create_certificates` does not duplicate certs or lines.
|
||||
|
||||
#### Change 4 — CoC report renders the parts table as a loop
|
||||
|
||||
File: `fusion_plating_reports/report/report_coc.xml` (tbody at line
|
||||
297-321).
|
||||
|
||||
```xml
|
||||
<tbody>
|
||||
<t t-foreach="doc.part_line_ids" t-as="pl">
|
||||
<tr>
|
||||
<td class="text-center" style="line-height: 1.3;">
|
||||
<div><t t-esc="pl.part_number or '-'"/></div>
|
||||
<div><t t-esc="pl.part_name or '-'"/></div>
|
||||
<div><t t-esc="pl.serial or '-'"/></div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-esc="pl.description or doc.process_description or ''"/>
|
||||
<t t-if="pl.spec_reference"><br/><em t-esc="pl.spec_reference"/></t>
|
||||
</td>
|
||||
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
|
||||
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
|
||||
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
|
||||
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
<!-- Defensive fallback: legacy cert with no part-lines (should not
|
||||
occur post-migration) renders the old single row. -->
|
||||
<tr t-if="not doc.part_line_ids">
|
||||
... existing _fp_resolve_part_identity() / _fp_resolve_customer_facing_description() row ...
|
||||
</tr>
|
||||
</tbody>
|
||||
```
|
||||
|
||||
Process / PO / Customer-Job columns: PO and Customer Job No. are SO-level
|
||||
(uniform), kept cert-level. The Process column shows each part's own
|
||||
customer-facing description + spec_reference (per 2026-05-28 policy).
|
||||
`page-break-inside: avoid` stays on each `<tr>` (per CLAUDE.md) so a part
|
||||
row never splits across a page.
|
||||
|
||||
#### Change 5 — Traveller lists all parts
|
||||
|
||||
File: `fusion_plating_jobs/report/report_fp_job_traveller.xml`.
|
||||
|
||||
The Item Information block today shows one part (`job.part_catalog_id`).
|
||||
Loop `job.sale_order_line_ids` (plating lines) so the operator sees every
|
||||
part in the batch with its qty. The routing/process table is unchanged
|
||||
(one shared recipe). Field reads stay defensively guarded.
|
||||
|
||||
#### Change 6 — Migration backfill
|
||||
|
||||
File: `fusion_plating_certificates/migrations/<new-version>/post-migrate.py`.
|
||||
|
||||
For each existing `fp.certificate` with no `part_line_ids`, create one
|
||||
part-line from its current singular fields so old certs render
|
||||
identically:
|
||||
|
||||
```python
|
||||
for cert in env['fp.certificate'].search([]):
|
||||
if cert.part_line_ids:
|
||||
continue
|
||||
pid = cert._fp_resolve_part_identity() # (number, name, serials)
|
||||
env['fp.certificate.part'].create({
|
||||
'certificate_id': cert.id, 'sequence': 10,
|
||||
'part_catalog_id': (cert.x_fc_job_id.part_catalog_id.id
|
||||
if cert.x_fc_job_id and cert.x_fc_job_id.part_catalog_id else False),
|
||||
'part_number': cert.part_number or (pid[0] or ''),
|
||||
'part_name': pid[1] or '',
|
||||
'description': cert._fp_resolve_customer_facing_description() or cert.process_description or '',
|
||||
'serial': pid[2] or '',
|
||||
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
|
||||
'spec_reference': cert.spec_reference or '',
|
||||
'quantity_shipped': cert.quantity_shipped or 0,
|
||||
'nc_quantity': cert.nc_quantity or 0,
|
||||
})
|
||||
```
|
||||
|
||||
Idempotent (skips certs that already have part-lines). 13 certs → 13
|
||||
single-part certs.
|
||||
|
||||
### Phase 2 — per-part refinement (separate plan)
|
||||
|
||||
- **Per-part thickness:** add `certificate_part_id` to
|
||||
`fp.thickness.reading`; associate readings + page-2 Fischerscope PDF
|
||||
merges per part; render a per-part thickness block under each part row;
|
||||
extend the `action_issue` thickness gate to require data on each part
|
||||
that needs thickness.
|
||||
- **Per-part box stickers:** today's consolidated "Multiple Line Items"
|
||||
sticker gains per-part detail / per-part labels.
|
||||
- **Cert form polish:** richer part-line editing UX.
|
||||
|
||||
Phase 2 is deferred and gets its own spec + plan once Phase 1 is live and
|
||||
validated on entech.
|
||||
|
||||
## Files touched (Phase 1)
|
||||
|
||||
| # | File | Change |
|
||||
|---|------|--------|
|
||||
| 1 | `fusion_plating_jobs/models/sale_order.py` | New `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key`; rewrite the `groups` key; define `_FP_EXPRESS_OVERRIDE_FIELDS`. |
|
||||
| 2 | `fusion_plating_certificates/models/fp_certificate_part.py` | New model. |
|
||||
| 3 | `fusion_plating_certificates/models/fp_certificate.py` | `part_line_ids` O2M. |
|
||||
| 4 | `fusion_plating_certificates/models/__init__.py` | import new model. |
|
||||
| 5 | `fusion_plating_certificates/security/ir.model.access.csv` | ACL for `fp.certificate.part`. |
|
||||
| 6 | `fusion_plating_certificates/views/fp_certificate_views.xml` | Part-lines list on the cert form. |
|
||||
| 7 | `fusion_plating_jobs/models/fp_job.py` | `_resolve_required_cert_types` union over all parts; `_fp_cert_source_lines`; `_fp_format_spec_ref`; part-line build in `_fp_create_certificates`. |
|
||||
| 8 | `fusion_plating_reports/report/report_coc.xml` | tbody loop over `part_line_ids` + legacy fallback row. |
|
||||
| 9 | `fusion_plating_jobs/report/report_fp_job_traveller.xml` | Item Information loops all parts. |
|
||||
| 10 | `fusion_plating_certificates/migrations/<ver>/post-migrate.py` | Backfill one part-line per existing cert. |
|
||||
| 11 | `__manifest__.py` × (jobs, certificates, reports) | Version bumps. |
|
||||
|
||||
## Migration
|
||||
|
||||
- New `fp.certificate.part` table created on install/upgrade.
|
||||
- Post-migrate backfills the 13 existing certs (idempotent).
|
||||
- Existing jobs/WOs untouched — `_fp_auto_create_job`'s `if existing:
|
||||
return` guard means only **new** confirmations regroup.
|
||||
- No re-grouping tool for open orders in Phase 1 (out of scope; can be a
|
||||
one-off odoo-shell script later if the shop wants it).
|
||||
|
||||
## Testing
|
||||
|
||||
These modules require Enterprise deps and **cannot install on the local
|
||||
Community box** (`fusion_plating` shows `installed=0` on `modsdev`), so:
|
||||
|
||||
- **Static checks (local):** `pyflakes` on every changed `.py`; lxml
|
||||
parse on changed XML; `node --check` not needed (no JS).
|
||||
- **Unit (where installable):** the grouping helpers are pure functions
|
||||
of a recipe/line — `_fp_recipe_signature` returns equal tuples for two
|
||||
structurally-identical recipes and unequal for divergent ones;
|
||||
`_fp_line_group_key` merges same-structure lines and splits on
|
||||
differing express overrides.
|
||||
- **Live verification (entech via odoo shell, read-only first):**
|
||||
1. Re-run the audit signature on SO-30083/30079/30071/30092 →
|
||||
confirm each collapses to 1 group.
|
||||
2. On a **clone** (or a fresh test SO), confirm SO with 4 same-process
|
||||
lines → 1 WO carrying 4 `sale_order_line_ids`; SO with 2 different
|
||||
processes → 2 WOs.
|
||||
3. Confirm `_fp_create_certificates` produces one CoC with 4
|
||||
part-lines; render the CoC PDF → 4 part rows, correct per-part
|
||||
part#/serial/spec/qty.
|
||||
4. Render an existing (migrated) single-part cert → identical to
|
||||
before.
|
||||
5. A line with masking ON + a line with masking OFF, same recipe →
|
||||
**2** WOs (express-signature split).
|
||||
|
||||
## Edge cases & open questions
|
||||
|
||||
| Item | Decision |
|
||||
|------|----------|
|
||||
| No-recipe lines | Each its own WO (unchanged). |
|
||||
| Same recipe structure, different express masking/bake | **Split** (express signature in the key). |
|
||||
| Repeated same part across lines (SO-30079) | One cert part-line **per line** (not per distinct part) — each carries that line's serial/qty. |
|
||||
| Part with `certificate_requirement='none'` on a WO whose other part needs a CoC | Combined CoC is produced (union) and **lists all shipped parts** — the cert documents the physical shipment. (Confirmed 2026-06-03.) |
|
||||
| Per-part NC qty | Default 0 (job-level scrap not split per part); editable at issue. (Confirmed 2026-06-03.) |
|
||||
| Job `part_catalog_id` when multi-part | First line (display/back-compat). |
|
||||
| WO naming | `WO-<parent>` (1 group) / `WO-<parent>-NN` (N groups) — unchanged. |
|
||||
| Existing open multi-line SOs already split into WOs | Left as-is; no auto re-group. |
|
||||
|
||||
**Confirmed during review (2026-06-03):** the union-cert "list all
|
||||
shipped parts even if one part opted out" behaviour, and the "per-part
|
||||
NC defaults to 0, editable at issue" behaviour are both approved.
|
||||
@@ -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.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from . import fp_thickness_reading
|
||||
from . import fp_certificate
|
||||
from . import fp_certificate_part
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
||||
from . import fp_delivery
|
||||
|
||||
@@ -87,6 +87,10 @@ class FpCertificate(models.Model):
|
||||
thickness_reading_ids = fields.One2many(
|
||||
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
|
||||
)
|
||||
part_line_ids = fields.One2many(
|
||||
'fp.certificate.part', 'certificate_id', string='Parts',
|
||||
help='One row per part covered by this certificate. Populated at '
|
||||
'cert creation from the work order\'s sale-order lines.')
|
||||
|
||||
# ----- Inline Fischerscope PDF upload (cert-local) ----------------------
|
||||
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpCertificatePart(models.Model):
|
||||
"""One row per part on a combined Certificate of Conformance.
|
||||
|
||||
A work order can cover several parts that share the same plating
|
||||
process; the combined CoC lists each with its own identity, spec,
|
||||
and quantities. Fields are snapshots taken at cert-creation time.
|
||||
"""
|
||||
_name = 'fp.certificate.part'
|
||||
_description = 'Certificate Part Line'
|
||||
_order = 'certificate_id, sequence, id'
|
||||
_rec_name = 'part_number'
|
||||
|
||||
certificate_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate',
|
||||
required=True, ondelete='cascade', index=True,)
|
||||
sequence = fields.Integer(default=10)
|
||||
sale_order_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Source SO Line',
|
||||
help='The order line this part row was built from (traceability).',)
|
||||
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
|
||||
part_number = fields.Char(string='Part Number') # snapshot
|
||||
part_name = fields.Char(string='Part Name') # snapshot
|
||||
description = fields.Char(string='Description') # customer-facing snapshot
|
||||
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
|
||||
customer_spec_id = fields.Many2one(
|
||||
'fusion.plating.customer.spec', string='Customer Spec',)
|
||||
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
|
||||
# Per-part; the parent fp.certificate keeps cert-level legacy totals.
|
||||
quantity_shipped = fields.Integer(string='Qty Shipped')
|
||||
nc_quantity = fields.Integer(string='NC Qty')
|
||||
@@ -11,3 +11,6 @@ access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_t
|
||||
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
|
||||
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -152,6 +152,21 @@
|
||||
invisible="trend_alert == 'ok'"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Parts" name="parts">
|
||||
<field name="part_line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="part_number"/>
|
||||
<field name="part_name"/>
|
||||
<field name="description"/>
|
||||
<field name="serial"/>
|
||||
<field name="customer_spec_id"/>
|
||||
<field name="spec_reference"/>
|
||||
<field name="quantity_shipped"/>
|
||||
<field name="nc_quantity"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Thickness Readings" name="readings">
|
||||
<field name="thickness_reading_ids">
|
||||
<list editable="bottom">
|
||||
|
||||
@@ -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.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Backfill one fp.certificate.part per existing certificate from its
|
||||
# legacy singular fields, so pre-existing certs render identically under
|
||||
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
|
||||
# because it reads x_fc_job_id, a jobs-module field; the part-line table
|
||||
# itself is created by the certificates upgrade, which runs first.
|
||||
import logging
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
if 'fp.certificate.part' not in env:
|
||||
return
|
||||
certs = env['fp.certificate'].search([])
|
||||
made = 0
|
||||
for cert in certs:
|
||||
if cert.part_line_ids:
|
||||
continue
|
||||
try:
|
||||
pid = cert._fp_resolve_part_identity() # (number, name, serials)
|
||||
except Exception:
|
||||
pid = ('', '', '')
|
||||
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
|
||||
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
|
||||
try:
|
||||
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
|
||||
except Exception:
|
||||
desc = cert.process_description or ''
|
||||
spec = cert.customer_spec_id if 'customer_spec_id' in cert._fields else False
|
||||
env['fp.certificate.part'].create({
|
||||
'certificate_id': cert.id, 'sequence': 10,
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': cert.part_number or (pid[0] or ''),
|
||||
'part_name': pid[1] or '',
|
||||
'description': desc,
|
||||
'serial': pid[2] or '',
|
||||
'customer_spec_id': spec.id if spec else False,
|
||||
'spec_reference': cert.spec_reference or '',
|
||||
'quantity_shipped': cert.quantity_shipped or 0,
|
||||
'nc_quantity': cert.nc_quantity or 0,
|
||||
})
|
||||
made += 1
|
||||
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)
|
||||
@@ -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
|
||||
|
||||
@@ -609,38 +609,47 @@ class FpJob(models.Model):
|
||||
matches the defensive pattern used elsewhere in this file.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# ---- Step 1 — partner + part baseline ----
|
||||
req = (
|
||||
self.part_catalog_id
|
||||
and self.part_catalog_id.certificate_requirement
|
||||
) or 'inherit'
|
||||
if req == 'inherit':
|
||||
wanted = set()
|
||||
# ---- Step 1 — partner + part baseline (union across all parts) ----
|
||||
def _partner_inherit_set():
|
||||
s = set()
|
||||
p = self.partner_id
|
||||
if p:
|
||||
if p.x_fc_send_coc:
|
||||
wanted.add('coc')
|
||||
s.add('coc')
|
||||
if p.x_fc_send_thickness_report:
|
||||
wanted.add('thickness_report')
|
||||
# Three aerospace/defence partner toggles. Field guards
|
||||
# let this module load even if fusion_plating_certificates
|
||||
# is at an older version that pre-dates the new fields.
|
||||
if ('x_fc_send_nadcap_cert' in p._fields
|
||||
and p.x_fc_send_nadcap_cert):
|
||||
wanted.add('nadcap_cert')
|
||||
if ('x_fc_send_mill_test' in p._fields
|
||||
and p.x_fc_send_mill_test):
|
||||
wanted.add('mill_test')
|
||||
if ('x_fc_send_customer_specific' in p._fields
|
||||
and p.x_fc_send_customer_specific):
|
||||
wanted.add('customer_specific')
|
||||
else:
|
||||
wanted = {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
s.add('thickness_report')
|
||||
if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
|
||||
s.add('nadcap_cert')
|
||||
if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
|
||||
s.add('mill_test')
|
||||
if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
|
||||
s.add('customer_specific')
|
||||
return s
|
||||
|
||||
def _explicit_set(req):
|
||||
return {
|
||||
'none': set(), 'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
}.get(req, {'coc'})
|
||||
|
||||
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
|
||||
if not parts and self.part_catalog_id:
|
||||
parts = self.part_catalog_id
|
||||
if not parts:
|
||||
parts = [False]
|
||||
wanted = set()
|
||||
inherit = None
|
||||
for part in parts:
|
||||
req = (part.certificate_requirement
|
||||
if part and 'certificate_requirement' in part._fields
|
||||
else 'inherit') or 'inherit'
|
||||
if req == 'inherit':
|
||||
if inherit is None:
|
||||
inherit = _partner_inherit_set()
|
||||
wanted |= inherit
|
||||
else:
|
||||
wanted |= _explicit_set(req)
|
||||
|
||||
# ---- Step 2 — recipe suppression (suppress-only) ----
|
||||
recipe = self.recipe_id
|
||||
if recipe:
|
||||
@@ -2655,6 +2664,58 @@ class FpJob(models.Model):
|
||||
self.name, e,
|
||||
)
|
||||
|
||||
def _fp_cert_source_lines(self):
|
||||
"""Plating SO lines this job covers (one cert part-line each)."""
|
||||
self.ensure_one()
|
||||
lines = self.sale_order_line_ids
|
||||
if not lines and self.sale_order_id:
|
||||
lines = self.sale_order_id.order_line
|
||||
return lines.filtered(
|
||||
lambda l: not l.display_type
|
||||
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
|
||||
|
||||
def _fp_format_spec_ref(self, spec):
|
||||
"""Format 'CODE Rev X' from a customer spec (or '')."""
|
||||
if not spec:
|
||||
return ''
|
||||
ref = spec.code or ''
|
||||
if 'revision' in spec._fields and spec.revision:
|
||||
ref = (f'{ref} Rev {spec.revision}' if ref
|
||||
else f'Rev {spec.revision}')
|
||||
return ref
|
||||
|
||||
def _fp_build_cert_part_commands(self):
|
||||
"""O2M create commands for fp.certificate.part — one per line."""
|
||||
self.ensure_one()
|
||||
cmds, seq = [], 10
|
||||
for sol in self._fp_cert_source_lines():
|
||||
part = sol.x_fc_part_catalog_id
|
||||
spec = (sol.x_fc_customer_spec_id
|
||||
if 'x_fc_customer_spec_id' in sol._fields else False)
|
||||
serials = ''
|
||||
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
|
||||
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
|
||||
# fp_customer_description() is a method (configurator), not a
|
||||
# field — use hasattr, not a _fields check.
|
||||
desc = (sol.fp_customer_description()
|
||||
if hasattr(sol, 'fp_customer_description')
|
||||
else (sol.name or ''))
|
||||
cmds.append((0, 0, {
|
||||
'sequence': seq,
|
||||
'sale_order_line_id': sol.id,
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': (part.part_number if part else '') or '',
|
||||
'part_name': (part.name if part else '') or '',
|
||||
'description': desc,
|
||||
'serial': serials,
|
||||
'customer_spec_id': spec.id if spec else False,
|
||||
'spec_reference': self._fp_format_spec_ref(spec),
|
||||
'quantity_shipped': int(sol.product_uom_qty or 0),
|
||||
'nc_quantity': 0,
|
||||
}))
|
||||
seq += 10
|
||||
return cmds
|
||||
|
||||
def _fp_create_certificates(self):
|
||||
"""Auto-create one draft fp.certificate per type returned by
|
||||
_resolve_required_cert_types. Idempotent per type — re-running
|
||||
@@ -2742,10 +2803,7 @@ class FpJob(models.Model):
|
||||
# spec_reference is what action_issue blocks on.
|
||||
# Format spec.code + revision for the cert text.
|
||||
if spec and 'spec_reference' in Cert._fields:
|
||||
ref = spec.code or ''
|
||||
if spec.revision:
|
||||
ref = (f'{ref} Rev {spec.revision}'
|
||||
if ref else f'Rev {spec.revision}')
|
||||
ref = self._fp_format_spec_ref(spec)
|
||||
if ref:
|
||||
vals['spec_reference'] = ref
|
||||
if 'customer_spec_id' in Cert._fields:
|
||||
@@ -2781,6 +2839,10 @@ class FpJob(models.Model):
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
if 'part_line_ids' in Cert._fields:
|
||||
part_cmds = self._fp_build_cert_part_commands()
|
||||
if part_cmds:
|
||||
vals['part_line_ids'] = part_cmds
|
||||
cert = Cert.create(vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer '
|
||||
|
||||
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',
|
||||
@@ -698,19 +787,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
|
||||
|
||||
104
fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py
Normal file
104
fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# -*- 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'…', '...')):
|
||||
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,
|
||||
'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()
|
||||
@@ -395,6 +395,66 @@ class SaleOrder(models.Model):
|
||||
return part.recipe_id
|
||||
return Node
|
||||
|
||||
def _fp_recipe_signature(self, recipe):
|
||||
"""Hashable structural signature of a recipe's step tree.
|
||||
|
||||
Two recipes with the same signature have identical processing
|
||||
steps and can share one work order. Excludes the recipe ROOT
|
||||
(its name carries the per-part ' — <part#>' suffix) and all
|
||||
numeric targets — those are per-part attestation data on the
|
||||
cert, not a batch splitter. Returns None for a missing recipe.
|
||||
"""
|
||||
if not recipe:
|
||||
return None
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
kids = Node.search(
|
||||
[('id', 'child_of', recipe.id),
|
||||
('node_type', 'in', ('sub_process', 'operation', 'step'))],
|
||||
order='parent_path, sequence')
|
||||
return tuple(
|
||||
(k.node_type,
|
||||
(k.kind_id.code if k.kind_id else '') or '',
|
||||
(k.name or '').strip().lower())
|
||||
for k in kids)
|
||||
|
||||
def _fp_line_express_signature(self, line):
|
||||
"""Per-line Express toggles that change which steps exist:
|
||||
masking on/off and bake present/absent. Lines differing here
|
||||
must not merge (the shared WO would silently drop one part's
|
||||
masking or bake step). Free-text bake instructions are NOT in
|
||||
the signature — both-present lines merge and the bake step
|
||||
carries the last applied line's text (known Phase-1 limit).
|
||||
When the Express fields are absent on a line's module, masking
|
||||
defaults to True and bake to False, so a non-Express line groups
|
||||
as masking-on / no-bake.
|
||||
"""
|
||||
F = line._fields
|
||||
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
|
||||
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
|
||||
if 'x_fc_bake_instructions' in F else False
|
||||
return (masking, has_bake)
|
||||
|
||||
def _fp_line_group_key(self, line, sig_cache=None):
|
||||
"""WO grouping key. Lines with the same key ride one work order.
|
||||
|
||||
`sig_cache` (optional) memoises recipe-id -> signature so a
|
||||
multi-line SO doesn't re-search the same recipe tree per line.
|
||||
"""
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
if not recipe:
|
||||
return ('no_recipe', line.id) # never merges
|
||||
if sig_cache is None:
|
||||
sig = self._fp_recipe_signature(recipe)
|
||||
else:
|
||||
if recipe.id not in sig_cache:
|
||||
sig_cache[recipe.id] = self._fp_recipe_signature(recipe)
|
||||
sig = sig_cache[recipe.id]
|
||||
if not sig:
|
||||
# A recipe with no step nodes has no structure to share —
|
||||
# don't let empty-tree shells silently merge into one WO.
|
||||
return ('no_recipe', line.id)
|
||||
return ('recipe', sig, self._fp_line_express_signature(line))
|
||||
|
||||
def _fp_auto_create_job(self):
|
||||
"""Create fp.job(s) from the SO's plating lines.
|
||||
|
||||
@@ -436,37 +496,14 @@ class SaleOrder(models.Model):
|
||||
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||
return
|
||||
|
||||
# Group by (recipe, part, spec, thickness, serial). Lines that
|
||||
# share ALL FIVE collapse into one WO. Bundling lines with
|
||||
# different specs / thicknesses / serials under one WO would
|
||||
# carry the first line's values onto the cert + sticker —
|
||||
# silent mis-attestation. No-recipe lines still get their own
|
||||
# group each.
|
||||
# Group by recipe structural signature (+ per-line masking/bake
|
||||
# toggles). Lines whose recipes have identical steps collapse onto
|
||||
# one WO; no-recipe lines stay separate. See spec
|
||||
# 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
|
||||
groups = {}
|
||||
unrecipe_idx = 0
|
||||
_sig_cache = {}
|
||||
for line in plating_lines:
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
part_id = (
|
||||
'x_fc_part_catalog_id' in line._fields
|
||||
and line.x_fc_part_catalog_id.id
|
||||
) or False
|
||||
spec_id = (
|
||||
'x_fc_customer_spec_id' in line._fields
|
||||
and line.x_fc_customer_spec_id.id
|
||||
) or False
|
||||
thickness_key = (
|
||||
'x_fc_thickness_range' in line._fields
|
||||
and (line.x_fc_thickness_range or '').strip()
|
||||
) or False
|
||||
serial_id = (
|
||||
'x_fc_serial_id' in line._fields
|
||||
and line.x_fc_serial_id.id
|
||||
) or False
|
||||
if recipe:
|
||||
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
|
||||
else:
|
||||
unrecipe_idx += 1
|
||||
key = ('no_recipe', unrecipe_idx)
|
||||
key = self._fp_line_group_key(line, sig_cache=_sig_cache)
|
||||
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
||||
|
||||
# Order groups by min line sequence so dash-suffixes mirror SO
|
||||
|
||||
@@ -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>
|
||||
@@ -96,36 +62,216 @@
|
||||
<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: 11mm; line-height: 11mm; text-align: center; }
|
||||
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; }
|
||||
.r-wo { height: 14mm; 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: 36mm; 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: 36mm; }
|
||||
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 33mm; height: 33mm; vertical-align: middle; }
|
||||
.qfwrap-full img { position: absolute; width: 41mm; height: 41mm; top: -4.1mm; left: -4.1mm; }
|
||||
/* Internal (Layout A) header QR — same ~10% quiet-zone crop, bigger box. */
|
||||
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 30mm; height: 30mm; vertical-align: middle; }
|
||||
.qfwrap-int img { position: absolute; width: 37.5mm; height: 37.5mm; top: -3.75mm; left: -3.75mm; }
|
||||
.r-fld { padding: 1mm 2.2mm; }
|
||||
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
|
||||
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal body — Layout A ===================== -->
|
||||
<template id="fp_job_internal_body">
|
||||
<div class="label-page"><div class="label">
|
||||
<table class="fpt">
|
||||
<tr style="height:22mm" class="rule">
|
||||
<td class="band pad">
|
||||
<span style="float:right"><span class="tag">INTERNAL</span></span>
|
||||
<span class="lbl" style="color:#fff">Work Order</span>
|
||||
<div style="font-size:30pt;font-weight:900;line-height:0.95"><t t-esc="d['wo']"/></div>
|
||||
</td>
|
||||
<td style="width:34mm;border-left:0.9mm solid #000;text-align:center;vertical-align:middle;padding:1mm">
|
||||
<span class="qfwrap-int"><img t-att-src="_qr"/></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:12mm" class="rule">
|
||||
<td class="pad" colspan="2">
|
||||
<span class="lbl">Part#</span>
|
||||
<span style="font-size:18pt;font-weight:900"> <t t-esc="d['part'] or '-'"/></span>
|
||||
<t t-if="d['rev']"><span style="font-size:11pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
|
||||
<span style="float:right">
|
||||
<t t-if="d['mask']"><span class="badge">MASK</span></t>
|
||||
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:10mm" class="rule">
|
||||
<td style="padding:0" colspan="2"><table class="fpt"><tr>
|
||||
<td class="pad vrule" style="width:25%"><span class="lbl">Customer</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['customer']"/></span></td>
|
||||
<td class="pad vrule" style="width:21%"><span class="lbl">PO#</span><br/><span style="font-size:11pt;font-weight:bold"><t t-esc="d['po'] or '-'"/></span></td>
|
||||
<td class="pad vrule" style="width:13%"><span class="lbl">Qty</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['qty']"/></span></td>
|
||||
<td class="pad vrule" style="width:22%"><span class="lbl">Due</span><br/><span style="font-size:10pt;font-weight:bold"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td class="pad"><span class="lbl">Thk</span><br/><span style="font-size:9.5pt;font-weight:bold"><t t-esc="d['thk'] or '-'"/></span></td>
|
||||
</tr></table></td>
|
||||
</tr>
|
||||
<t t-if="d['bake']">
|
||||
<tr style="height:13mm" class="rule">
|
||||
<td class="pad" colspan="2" style="vertical-align:top;padding-top:1.5mm">
|
||||
<span class="inshead">BAKE</span>
|
||||
<span class="instext" style="font-size:10pt;line-height:1.18"> <t t-esc="d['bake']"/></span>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr>
|
||||
<td class="pad" colspan="2" style="vertical-align:top;padding:1.5mm 2.5mm 3.5mm 2.5mm;overflow:hidden">
|
||||
<span class="inshead">NOTES</span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.18;margin-top:1.5mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div></div>
|
||||
</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 class="vrule" style="width:55%"><span class="lbl">Due</span><span style="font-size:9pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td><span class="lbl">Thk (mils)</span><span style="font-size:8.5pt;font-weight:bold;display:block"><t t-esc="d['thk'] or '-'"/></span></td>
|
||||
</tr></table></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<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">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) ===================== -->
|
||||
<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="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<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_internal_body"/>
|
||||
</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="_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>
|
||||
|
||||
@@ -142,6 +142,16 @@
|
||||
<span t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/><br/>
|
||||
<strong>S/N:</strong>
|
||||
<t t-if="'serial_number' in job._fields"><span t-esc="job.serial_number or ''"/></t>
|
||||
<!-- Multi-part batch: list every distinct part on this WO
|
||||
(the labeled block above details the primary part). -->
|
||||
<t t-set="trav_lines" t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else False"/>
|
||||
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id') if trav_lines else False"/>
|
||||
<t t-if="trav_parts and len(trav_parts) > 1">
|
||||
<br/><strong>Batch parts:</strong>
|
||||
<t t-foreach="trav_parts" t-as="tp">
|
||||
<div style="font-size: 7pt;"><span t-esc="tp.part_number or '—'"/><t t-if="'revision' in tp._fields and tp.revision"> Rev <span t-esc="tp.revision"/></t></div>
|
||||
</t>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
|
||||
@@ -10,3 +10,5 @@ from . import test_autopause_cron
|
||||
from . import test_post_shop_states
|
||||
from . import test_recipe_cert_suppression
|
||||
from . import test_order_ship_state
|
||||
from . import test_combined_cert_creation
|
||||
from . import test_wo_recipe_grouping
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestCombinedCertCreation(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'CertCust',
|
||||
'x_fc_send_coc': True, # drives the coc requirement
|
||||
})
|
||||
self.product = self.env['product.product'].create({'name': 'W'})
|
||||
self.part_a = self.env['fp.part.catalog'].create({
|
||||
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
|
||||
self.part_b = self.env['fp.part.catalog'].create({
|
||||
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
|
||||
self.so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
|
||||
'x_fc_part_catalog_id': self.part_a.id}),
|
||||
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
|
||||
'x_fc_part_catalog_id': self.part_b.id}),
|
||||
],
|
||||
})
|
||||
|
||||
def test_combined_cert_has_one_line_per_so_line(self):
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 5.0,
|
||||
'sale_order_id': self.so.id,
|
||||
'part_catalog_id': self.part_a.id,
|
||||
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
|
||||
})
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
|
||||
self.assertEqual(len(cert), 1, 'one combined CoC')
|
||||
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
|
||||
self.assertEqual(
|
||||
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
|
||||
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
|
||||
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
|
||||
|
||||
def test_part_lines_fall_back_to_so_order_line(self):
|
||||
# Job without an explicit sale_order_line_ids M2M still builds
|
||||
# one part-line per plating line via the SO order_line fallback.
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 5.0,
|
||||
'sale_order_id': self.so.id,
|
||||
'part_catalog_id': self.part_a.id,
|
||||
})
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
|
||||
self.assertEqual(len(cert), 1)
|
||||
self.assertEqual(len(cert.part_line_ids), 2,
|
||||
'falls back to SO order_line when no M2M lines set')
|
||||
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestWoRecipeGrouping(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.SO = self.env['sale.order']
|
||||
self.Node = self.env['fusion.plating.process.node']
|
||||
# kind_id is required on process.node; reuse any seeded kind so
|
||||
# node creation doesn't depend on the default lookup resolving.
|
||||
self.kind = self.env['fp.step.kind'].search([], limit=1)
|
||||
|
||||
def _node_vals(self, name, node_type):
|
||||
v = {'name': name, 'node_type': node_type}
|
||||
if self.kind:
|
||||
v['kind_id'] = self.kind.id
|
||||
return v
|
||||
|
||||
def _recipe(self, name, step_names):
|
||||
root = self.Node.create(self._node_vals(name, 'recipe'))
|
||||
seq = 10
|
||||
for sn in step_names:
|
||||
v = self._node_vals(sn, 'step')
|
||||
v.update({'parent_id': root.id, 'sequence': seq})
|
||||
self.Node.create(v)
|
||||
seq += 10
|
||||
return root
|
||||
|
||||
def test_identical_structure_same_signature(self):
|
||||
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
self.assertEqual(
|
||||
self.SO._fp_recipe_signature(r1),
|
||||
self.SO._fp_recipe_signature(r2),
|
||||
'clones with identical steps share a signature')
|
||||
|
||||
def test_different_structure_different_signature(self):
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
|
||||
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
|
||||
self.assertNotEqual(
|
||||
self.SO._fp_recipe_signature(r1),
|
||||
self.SO._fp_recipe_signature(r2))
|
||||
|
||||
def test_so_groups_same_structure_into_one_wo(self):
|
||||
partner = self.env['res.partner'].create({'name': 'G'})
|
||||
product = self.env['product.product'].create({'name': 'P'})
|
||||
pa = self.env['fp.part.catalog'].create({
|
||||
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
|
||||
pb = self.env['fp.part.catalog'].create({
|
||||
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
|
||||
pc = self.env['fp.part.catalog'].create({
|
||||
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
|
||||
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
|
||||
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pa.id,
|
||||
'x_fc_process_variant_id': r1.id}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pb.id,
|
||||
'x_fc_process_variant_id': r2.id}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pc.id,
|
||||
'x_fc_process_variant_id': r3.id}),
|
||||
],
|
||||
})
|
||||
so._fp_auto_create_job()
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
||||
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
|
||||
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
|
||||
self.assertEqual(sizes, [1, 2])
|
||||
|
||||
def test_masking_toggle_splits_same_structure(self):
|
||||
partner = self.env['res.partner'].create({'name': 'M'})
|
||||
product = self.env['product.product'].create({'name': 'P'})
|
||||
pa = self.env['fp.part.catalog'].create({
|
||||
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
|
||||
pb = self.env['fp.part.catalog'].create({
|
||||
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
|
||||
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
|
||||
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pa.id,
|
||||
'x_fc_process_variant_id': r1.id,
|
||||
'x_fc_masking_enabled': True}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
|
||||
'x_fc_part_catalog_id': pb.id,
|
||||
'x_fc_process_variant_id': r2.id,
|
||||
'x_fc_masking_enabled': False}),
|
||||
],
|
||||
})
|
||||
so._fp_auto_create_job()
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
||||
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')
|
||||
@@ -302,6 +302,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>
|
||||
|
||||
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>
|
||||
@@ -5,3 +5,4 @@
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
from . import controllers
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.28.3',
|
||||
'version': '19.0.3.29.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
@@ -44,6 +44,7 @@ Provides:
|
||||
'views/fp_racking_inspection_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_receiving_menu.xml',
|
||||
'views/fp_box_views.xml',
|
||||
'views/fusion_shipment_inherit_views.xml',
|
||||
'wizards/fp_label_manual_wizard_views.xml',
|
||||
'wizards/fp_label_generate_wizard_views.xml',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
from . import fp_box_controller
|
||||
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Box scan endpoint. The per-box QR on the External Job Sticker encodes
|
||||
``/fp/box/<id>``; scanning it (logged-in operator on the tablet) lands on
|
||||
the box's backend form where they can advance its status."""
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpBoxScan(http.Controller):
|
||||
|
||||
@http.route(['/fp/box/<int:box_id>'], type='http', auth='user', website=False)
|
||||
def fp_box_scan(self, box_id, **kw):
|
||||
box = request.env['fp.box'].sudo().browse(box_id).exists()
|
||||
if not box:
|
||||
return request.not_found()
|
||||
# Land on the box form in the web client (operator advances status there).
|
||||
return request.redirect('/web#id=%s&model=fp.box&view_type=form' % box.id)
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# Backfill fp.box records for receivings that were counted BEFORE box-level
|
||||
# tracking shipped. Idempotent: skips any receiving that already has boxes.
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
from odoo import api, SUPERUSER_ID
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
recs = env['fp.receiving'].search([('box_count_in', '>', 0)])
|
||||
done = 0
|
||||
for rec in recs:
|
||||
if not rec.box_ids:
|
||||
rec._fp_sync_boxes()
|
||||
done += 1
|
||||
_logger.info('fp.box backfill: created boxes for %s receiving(s)', done)
|
||||
@@ -6,6 +6,7 @@
|
||||
from . import fp_receiving_damage
|
||||
from . import fp_receiving_line
|
||||
from . import fp_outbound_package
|
||||
from . import fp_box
|
||||
from . import fp_receiving
|
||||
from . import fp_racking_inspection
|
||||
from . import sale_order
|
||||
|
||||
111
fusion_plating/fusion_plating_receiving/models/fp_box.py
Normal file
111
fusion_plating/fusion_plating_receiving/models/fp_box.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Per-box registry for box-level tracking.
|
||||
|
||||
One `fp.box` per physical box received against a `fp.receiving`. Auto-created
|
||||
when the receiver enters `box_count_in` and marks the receiving Counted
|
||||
(see `fp.receiving._fp_sync_boxes`). Each box carries a sequence number
|
||||
(n of N), a status that advances through the shop, and a scannable identity
|
||||
(`/fp/box/<id>`) printed on the External Job Sticker — one label per box.
|
||||
|
||||
Box-level tracking (not box CONTENTS): we track WHICH box and WHERE it is,
|
||||
not the per-box part breakdown. The same boxes go back to the customer
|
||||
(Sub 8), so reconciliation flags any box that never reaches `shipped`.
|
||||
"""
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
STATE_ORDER = ['received', 'racked', 'in_process', 'packed', 'shipped']
|
||||
|
||||
|
||||
class FpBox(models.Model):
|
||||
_name = 'fp.box'
|
||||
_description = 'Fusion Plating — Tracked Box'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'receiving_id, box_number'
|
||||
|
||||
name = fields.Char(string='Box', compute='_compute_name', store=True)
|
||||
box_number = fields.Integer(string='Box #', required=True, default=1, tracking=True)
|
||||
box_count = fields.Integer(string='Of', tracking=True,
|
||||
help='Total boxes in this receiving (N in "n of N").')
|
||||
|
||||
receiving_id = fields.Many2one('fp.receiving', string='Receiving', required=True,
|
||||
ondelete='cascade', index=True)
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order',
|
||||
related='receiving_id.sale_order_id', store=True, index=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Customer',
|
||||
related='receiving_id.partner_id', store=True)
|
||||
job_id = fields.Many2one('fp.job', string='Work Order', index=True,
|
||||
help='Resolved job for this box (single-job SO). '
|
||||
'The sticker resolves boxes via the SO when blank.')
|
||||
company_id = fields.Many2one('res.company', string='Company',
|
||||
default=lambda self: self.env.company, index=True)
|
||||
|
||||
state = fields.Selection([
|
||||
('received', 'Received'),
|
||||
('racked', 'Racked'),
|
||||
('in_process', 'In Process'),
|
||||
('packed', 'Packed'),
|
||||
('shipped', 'Shipped'),
|
||||
('lost', 'Lost'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='received', required=True, tracking=True, index=True)
|
||||
|
||||
location_note = fields.Char(string='Location / Note', tracking=True,
|
||||
help='Free text — where is this box now (rack, bay, shelf).')
|
||||
scan_url = fields.Char(string='Scan URL', compute='_compute_scan_url')
|
||||
|
||||
_box_uniq = models.Constraint(
|
||||
'UNIQUE(receiving_id, box_number)',
|
||||
'Box number must be unique within a receiving.')
|
||||
|
||||
# ------------------------------------------------------------------ computes
|
||||
@api.depends('box_number', 'box_count', 'receiving_id.name', 'sale_order_id.name')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
base = rec.receiving_id.name or (rec.sale_order_id.name if rec.sale_order_id else '') or 'BOX'
|
||||
rec.name = '%s · Box %d/%d' % (base, rec.box_number or 1, rec.box_count or 1)
|
||||
|
||||
def _compute_scan_url(self):
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
for rec in self:
|
||||
rec.scan_url = ('%s/fp/box/%s' % (base, rec.id)) if rec.id else ''
|
||||
|
||||
# ------------------------------------------------------------------ workflow
|
||||
def _set_state(self, new_state):
|
||||
for rec in self:
|
||||
old = dict(rec._fields['state'].selection).get(rec.state, rec.state)
|
||||
new = dict(rec._fields['state'].selection).get(new_state, new_state)
|
||||
rec.state = new_state
|
||||
rec.message_post(body=_(
|
||||
'Box %(n)s/%(N)s: %(old)s → %(new)s by %(u)s'
|
||||
) % {'n': rec.box_number, 'N': rec.box_count,
|
||||
'old': old, 'new': new, 'u': self.env.user.name})
|
||||
|
||||
def action_set_racked(self):
|
||||
self._set_state('racked')
|
||||
|
||||
def action_set_in_process(self):
|
||||
self._set_state('in_process')
|
||||
|
||||
def action_set_packed(self):
|
||||
self._set_state('packed')
|
||||
|
||||
def action_set_shipped(self):
|
||||
self._set_state('shipped')
|
||||
|
||||
def action_set_lost(self):
|
||||
self._set_state('lost')
|
||||
|
||||
def action_reset_received(self):
|
||||
self._set_state('received')
|
||||
|
||||
def action_open_record(self):
|
||||
"""Used by the /fp/box/<id> scan endpoint to land on the box form."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.box',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -86,6 +86,14 @@ class FpReceiving(models.Model):
|
||||
'dropped off. Receiving is box count only — parts are '
|
||||
'inspected by the racking crew when boxes are opened.',
|
||||
)
|
||||
box_ids = fields.One2many(
|
||||
'fp.box', 'receiving_id', string='Tracked Boxes',
|
||||
help='One record per physical box (box-level tracking). Auto-created '
|
||||
'when the receiving is marked Counted.',
|
||||
)
|
||||
box_count_tracked = fields.Integer(
|
||||
string='Boxes Tracked', compute='_compute_box_count_tracked',
|
||||
)
|
||||
expected_qty = fields.Integer(string='Expected Qty', help='Total quantity expected from the sale order.')
|
||||
received_qty = fields.Integer(string='Received Qty', help='Total quantity actually received.')
|
||||
qty_match = fields.Boolean(
|
||||
@@ -1182,6 +1190,56 @@ class FpReceiving(models.Model):
|
||||
# -------------------------------------------------------------------------
|
||||
# Sub 8 — box-count-only actions (new primary flow)
|
||||
# -------------------------------------------------------------------------
|
||||
@api.depends('box_ids')
|
||||
def _compute_box_count_tracked(self):
|
||||
for rec in self:
|
||||
rec.box_count_tracked = len(rec.box_ids)
|
||||
|
||||
def _fp_sync_boxes(self):
|
||||
"""Create/sync one fp.box per received box (idempotent).
|
||||
|
||||
Grows the box set when box_count_in increases; removes only the
|
||||
trailing boxes that are still 'received' when it shrinks (never
|
||||
touches a box that has already advanced through the shop).
|
||||
Resolves job_id from the SO's first job when one exists.
|
||||
"""
|
||||
Box = self.env['fp.box']
|
||||
for rec in self:
|
||||
n = int(rec.box_count_in or 0)
|
||||
existing = rec.box_ids.sorted('box_number')
|
||||
if existing:
|
||||
existing.write({'box_count': n})
|
||||
job = False
|
||||
if rec.sale_order_id and 'fp.job' in self.env:
|
||||
job = self.env['fp.job'].sudo().search(
|
||||
[('sale_order_id', '=', rec.sale_order_id.id)], limit=1)
|
||||
cur = len(existing)
|
||||
if n > cur:
|
||||
Box.create([{
|
||||
'receiving_id': rec.id,
|
||||
'box_number': i,
|
||||
'box_count': n,
|
||||
'job_id': job.id if job else False,
|
||||
} for i in range(cur + 1, n + 1)])
|
||||
elif n < cur:
|
||||
drop = existing.filtered(
|
||||
lambda b: b.box_number > n and b.state == 'received')
|
||||
drop.unlink()
|
||||
if job:
|
||||
rec.box_ids.filtered(lambda b: not b.job_id).write({'job_id': job.id})
|
||||
|
||||
def action_view_boxes(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Boxes'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.box',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('receiving_id', '=', self.id)],
|
||||
'context': {'default_receiving_id': self.id,
|
||||
'default_box_count': self.box_count_in or 1},
|
||||
}
|
||||
|
||||
def action_mark_counted(self):
|
||||
"""Receiver has counted the boxes on the dock. Move to Counted."""
|
||||
for rec in self:
|
||||
@@ -1197,6 +1255,7 @@ class FpReceiving(models.Model):
|
||||
rec.message_post(body=_(
|
||||
'%(user)s counted %(n)d box(es) at receiving.'
|
||||
) % {'user': self.env.user.name, 'n': rec.box_count_in})
|
||||
rec._fp_sync_boxes()
|
||||
|
||||
def action_mark_staged(self):
|
||||
"""Deprecated 2026-05-20 — `staged` state was dead ceremony
|
||||
|
||||
@@ -14,6 +14,9 @@ access_fp_racking_inspection_manager,fp.racking.inspection.manager,model_fp_rack
|
||||
access_fp_racking_inspection_line_operator,fp.racking.inspection.line.operator,model_fp_racking_inspection_line,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_racking_inspection_line_supervisor,fp.racking.inspection.line.supervisor,model_fp_racking_inspection_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,model_fp_racking_inspection_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_label_manual_wizard_operator,fp.label.manual.wizard.operator,model_fp_label_manual_wizard,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_label_generate_wizard_operator,fp.label.generate.wizard.operator,model_fp_label_generate_wizard,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_outbound_package_operator,fp.outbound.package.operator,model_fp_outbound_package,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_label_manual_wizard_receiver,fp.label.manual.wizard.receiver,model_fp_label_manual_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_label_manual_wizard_supervisor,fp.label.manual.wizard.supervisor,model_fp_label_manual_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_label_manual_wizard_manager,fp.label.manual.wizard.manager,model_fp_label_manual_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
@@ -23,3 +26,6 @@ access_fp_label_generate_wizard_manager,fp.label.generate.wizard.manager,model_f
|
||||
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_box_operator,fp.box.operator,model_fp_box,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_box_supervisor,fp.box.supervisor,model_fp_box,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_box_manager,fp.box.manager,model_fp_box,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
145
fusion_plating/fusion_plating_receiving/views/fp_box_views.xml
Normal file
145
fusion_plating/fusion_plating_receiving/views/fp_box_views.xml
Normal file
@@ -0,0 +1,145 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Box-level tracking — fp.box list / form / search / kanban + menu.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ===== List ===== -->
|
||||
<record id="fp_box_view_list" model="ir.ui.view">
|
||||
<field name="name">fp.box.list</field>
|
||||
<field name="model">fp.box</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Boxes" decoration-muted="state in ('shipped','cancelled')" decoration-danger="state == 'lost'">
|
||||
<field name="box_number"/>
|
||||
<field name="box_count"/>
|
||||
<field name="name"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="job_id"/>
|
||||
<field name="location_note"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'received'"
|
||||
decoration-warning="state in ('racked','in_process','packed')"
|
||||
decoration-success="state == 'shipped'"
|
||||
decoration-danger="state == 'lost'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Form ===== -->
|
||||
<record id="fp_box_view_form" model="ir.ui.view">
|
||||
<field name="name">fp.box.form</field>
|
||||
<field name="model">fp.box</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_set_racked" type="object" string="Mark Racked"
|
||||
class="btn-primary" invisible="state != 'received'"/>
|
||||
<button name="action_set_in_process" type="object" string="Mark In Process"
|
||||
class="btn-primary" invisible="state != 'racked'"/>
|
||||
<button name="action_set_packed" type="object" string="Mark Packed"
|
||||
class="btn-primary" invisible="state != 'in_process'"/>
|
||||
<button name="action_set_shipped" type="object" string="Mark Shipped"
|
||||
class="btn-primary" invisible="state != 'packed'"/>
|
||||
<button name="action_set_lost" type="object" string="Flag Lost"
|
||||
invisible="state in ('shipped','lost','cancelled')"/>
|
||||
<button name="action_reset_received" type="object" string="Reset to Received"
|
||||
groups="fusion_plating.group_fp_shop_manager_v2"
|
||||
invisible="state == 'received'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="received,racked,in_process,packed,shipped"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<label for="box_number" string="Box"/>
|
||||
<div>
|
||||
<field name="box_number" class="oe_inline"/> /
|
||||
<field name="box_count" class="oe_inline"/>
|
||||
</div>
|
||||
<field name="receiving_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="job_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="location_note"/>
|
||||
<field name="scan_url" widget="url" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Search ===== -->
|
||||
<record id="fp_box_view_search" model="ir.ui.view">
|
||||
<field name="name">fp.box.search</field>
|
||||
<field name="model">fp.box</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="job_id"/>
|
||||
<field name="receiving_id"/>
|
||||
<filter name="open" string="Open (not shipped)" domain="[('state','not in',('shipped','cancelled'))]"/>
|
||||
<filter name="received" string="Received" domain="[('state','=','received')]"/>
|
||||
<filter name="in_process" string="In Process" domain="[('state','in',('racked','in_process','packed'))]"/>
|
||||
<filter name="shipped" string="Shipped" domain="[('state','=','shipped')]"/>
|
||||
<filter name="lost" string="Lost" domain="[('state','=','lost')]"/>
|
||||
<group>
|
||||
<filter name="g_state" string="Status" context="{'group_by':'state'}"/>
|
||||
<filter name="g_customer" string="Customer" context="{'group_by':'partner_id'}"/>
|
||||
<filter name="g_receiving" string="Receiving" context="{'group_by':'receiving_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Kanban (by status) ===== -->
|
||||
<record id="fp_box_view_kanban" model="ir.ui.view">
|
||||
<field name="name">fp.box.kanban</field>
|
||||
<field name="model">fp.box</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state" class="o_kanban_small_column">
|
||||
<field name="state"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_content">
|
||||
<strong><field name="name"/></strong>
|
||||
<div><field name="partner_id"/></div>
|
||||
<div t-if="record.location_note.raw_value">
|
||||
<span class="text-muted">@ </span><field name="location_note"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Action ===== -->
|
||||
<record id="action_fp_box" model="ir.actions.act_window">
|
||||
<field name="name">Boxes</field>
|
||||
<field name="res_model">fp.box</field>
|
||||
<field name="view_mode">list,kanban,form</field>
|
||||
<field name="search_view_id" ref="fp_box_view_search"/>
|
||||
<field name="context">{'search_default_open': 1}</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Menu ===== -->
|
||||
<menuitem id="menu_fp_box"
|
||||
name="Boxes"
|
||||
parent="menu_fp_receiving_root"
|
||||
action="action_fp_box"
|
||||
sequence="35"/>
|
||||
|
||||
</odoo>
|
||||
@@ -25,11 +25,15 @@
|
||||
<!-- Renamed from "Receiving & Inspection" so the same dock workflow -->
|
||||
<!-- — parts coming in AND parts going out — lives in one place. -->
|
||||
<!-- Logistics module reparents its 5 menu items under this root. -->
|
||||
<!-- 2026-06-02: opened to Technician (was Shop Manager+) so technicians
|
||||
can browse + edit receiving in the backend, not just the tablet card.
|
||||
All higher roles imply Technician, so they keep access; sales-only
|
||||
roles (no Technician) stay excluded. Children inherit this gate. -->
|
||||
<menuitem id="menu_fp_receiving_root"
|
||||
name="Shipping & Receiving"
|
||||
parent="fusion_plating.menu_fp_root"
|
||||
sequence="15"
|
||||
groups="fusion_plating.group_fp_shop_manager_v2"/>
|
||||
groups="fusion_plating.group_fp_technician"/>
|
||||
|
||||
<!-- Inbound (sequences 10–30) -->
|
||||
<menuitem id="menu_fp_receiving_all"
|
||||
|
||||
@@ -125,6 +125,15 @@
|
||||
</div>
|
||||
<field name="x_fc_has_label_zpl" invisible="1"/>
|
||||
</button>
|
||||
<button name="action_view_boxes"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-cubes"
|
||||
invisible="box_count_tracked == 0">
|
||||
<field name="box_count_tracked"
|
||||
widget="statinfo"
|
||||
string="Boxes"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.11.34.0',
|
||||
'version': '19.0.11.35.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
|
||||
@@ -295,7 +295,26 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<t t-foreach="doc.part_line_ids" t-as="pl">
|
||||
<tr style="page-break-inside: avoid;">
|
||||
<td class="text-center" style="line-height: 1.3;">
|
||||
<div><t t-esc="pl.part_number or '-'"/></div>
|
||||
<div><t t-esc="pl.part_name or '-'"/></div>
|
||||
<div><t t-esc="pl.serial or '-'"/></div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-esc="pl.description or doc.process_description or ''"/>
|
||||
<t t-if="pl.spec_reference">
|
||||
<br/><em t-esc="pl.spec_reference"/>
|
||||
</t>
|
||||
</td>
|
||||
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
|
||||
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
|
||||
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
|
||||
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr t-if="not doc.part_line_ids" style="page-break-inside: avoid;">
|
||||
<td class="text-center" style="line-height: 1.3;">
|
||||
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
|
||||
<div><t t-esc="pid[0] or '-'"/></div>
|
||||
@@ -303,11 +322,6 @@
|
||||
<div><t t-esc="pid[2] or '-'"/></div>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Customer-facing description is the cert's
|
||||
spec / certificate info (client request
|
||||
2026-05-28). Falls back to the recipe-
|
||||
derived process_description. spec_reference,
|
||||
now optional, still prints below when set. -->
|
||||
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
|
||||
<t t-esc="cust_desc or doc.process_description or ''"/>
|
||||
<t t-if="doc.spec_reference">
|
||||
|
||||
@@ -274,7 +274,14 @@
|
||||
<!-- Per-box loop: renders one sticker page per physical box in
|
||||
the line/job qty. When _qty_total is missing/0/1, falls
|
||||
back to a single render (no "X / N" indicator). -->
|
||||
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
|
||||
<!-- Hard safety cap (defense in depth): never render more than 100
|
||||
label pages in one pass, regardless of what _qty_total resolves
|
||||
to. A sticker is a per-box identification label; rendering
|
||||
thousands (each with an inlined logo + QR data-URI) OOMs the
|
||||
worker. WO-30072 (qty 2000 parts) crashed the PDF engine here. -->
|
||||
<t t-set="_label_count_raw" t-value="int(_qty_total or 1)"/>
|
||||
<t t-set="_label_count" t-value="100 if _label_count_raw > 100 else (1 if _label_count_raw < 1 else _label_count_raw)"/>
|
||||
<t t-foreach="range(_label_count)" t-as="_box_idx0">
|
||||
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
|
||||
<div class="fp-sticker">
|
||||
<!-- 3-cell header: Logo | WO# | QR -->
|
||||
@@ -517,7 +524,13 @@
|
||||
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
|
||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||
<!-- One label per physical BOX (box_count_in on the
|
||||
SO's receiving), NOT per part. Was
|
||||
line.product_uom_qty, which rendered one label per
|
||||
part and OOM'd on large qty (WO-30072 = 2000).
|
||||
Falls back to 1 when no box count is recorded. -->
|
||||
<t t-set="_box_count" t-value="int(sum(so.env['fp.receiving'].sudo().search([('sale_order_id', '=', so.id)]).mapped('box_count_in')) or 0) if 'fp.receiving' in so.env else 0"/>
|
||||
<t t-set="_qty_total" t-value="_box_count if _box_count > 0 else 1"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
@@ -572,7 +585,13 @@
|
||||
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
|
||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||
<!-- One label per physical BOX (box_count_in on the
|
||||
SO's receiving), NOT per part. Was
|
||||
line.product_uom_qty, which rendered one label per
|
||||
part and OOM'd on large qty (WO-30072 = 2000).
|
||||
Falls back to 1 when no box count is recorded. -->
|
||||
<t t-set="_box_count" t-value="int(sum(so.env['fp.receiving'].sudo().search([('sale_order_id', '=', so.id)]).mapped('box_count_in')) or 0) if 'fp.receiving' in so.env else 0"/>
|
||||
<t t-set="_qty_total" t-value="_box_count if _box_count > 0 else 1"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Internal override: read x_fc_internal_description -->
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.36.2.0',
|
||||
'version': '19.0.37.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||
'description': """
|
||||
@@ -109,6 +109,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/pin_setup.js',
|
||||
# ---- Racking panel (multi-rack split, Phase 1 — 2026-06-03) ----
|
||||
# Loaded before job_workspace.js (which imports RackingPanel).
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_racking_panel.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/racking_panel.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/racking_panel.js',
|
||||
# ---- Job Workspace (Phase 1 — tablet redesign) ----
|
||||
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
||||
|
||||
@@ -10,3 +10,4 @@ from . import workspace_controller
|
||||
from . import landing_controller
|
||||
from . import tablet_controller
|
||||
from . import plant_kanban
|
||||
from . import racking_controller
|
||||
|
||||
@@ -124,8 +124,18 @@ class FpTabletMoveController(http.Controller):
|
||||
hasattr(to_step, '_fp_should_block_predecessors')
|
||||
and to_step._fp_should_block_predecessors()
|
||||
):
|
||||
# Partial-flow (2026-06-02): only an unfinished step STRICTLY
|
||||
# BETWEEN from_step and to_step blocks the move (you'd be skipping
|
||||
# an incomplete intermediate stage). The from_step itself is
|
||||
# in-progress BY DEFINITION when advancing partial parts out of
|
||||
# it — counting it (or any earlier step) as an "unfinished
|
||||
# predecessor" blocked every partial advance to a not-yet-started
|
||||
# next step. Steps before from_step are irrelevant: the parts
|
||||
# being moved are physically at from_step, ready for the next
|
||||
# stage. Backward moves (rework: from > to) yield an empty range
|
||||
# and are never predecessor-blocked.
|
||||
unfinished = to_step.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence < to_step.sequence
|
||||
lambda s: from_step.sequence < s.sequence < to_step.sequence
|
||||
and s.state not in ('done', 'skipped', 'cancelled')
|
||||
)
|
||||
if unfinished:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Multi-rack splitting at Racking — Phase 1 controller. Endpoints run as the
|
||||
# technician (request.env.user); the rack-load + division logic lives on
|
||||
# fp.rack.load (core + fusion_plating_jobs extension).
|
||||
|
||||
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 _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': load.id,
|
||||
'name': load.name,
|
||||
'rack_id': load.rack_id.id or False,
|
||||
'rack_name': load.rack_id.name or '',
|
||||
'rack_capacity': load.rack_id.capacity or 0,
|
||||
'qty': load.qty_total,
|
||||
'over_capacity': bool(
|
||||
load.rack_id and load.rack_id.capacity
|
||||
and load.qty_total > load.rack_id.capacity),
|
||||
'moved': bool(load.current_step_id),
|
||||
} for load in loads],
|
||||
}
|
||||
|
||||
@http.route('/fp/racking/load', type='jsonrpc', auth='user')
|
||||
def load(self, job_id):
|
||||
job = self._job(job_id)
|
||||
request.env['fp.rack.load']._fp_ensure_seeded(job)
|
||||
return self._payload(job)
|
||||
|
||||
@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._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._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))
|
||||
job = load.line_ids[:1].job_id
|
||||
try:
|
||||
load._fp_set_qty(qty)
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0])}
|
||||
return self._payload(job)
|
||||
|
||||
@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._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
|
||||
if 'racking_state' in rack._fields:
|
||||
rack.racking_state = 'loaded'
|
||||
return self._payload(load.line_ids[:1].job_id)
|
||||
@@ -68,6 +68,18 @@ class FpWorkspaceController(http.Controller):
|
||||
override = job.override_ids.filtered(
|
||||
lambda o, n=step.recipe_node_id: o.node_id.id == n.id
|
||||
) if 'override_ids' in job._fields else env['fp.job.node.override']
|
||||
# Masking reference image(s)/PDF(s) attached at Express order entry.
|
||||
# sudo: low-priv operators can read fp.job.step but not always the
|
||||
# linked ir.attachment (rule 13m). The files are safe to surface.
|
||||
mask_atts = (step.sudo().x_fc_masking_attachment_ids
|
||||
if 'x_fc_masking_attachment_ids' in step._fields
|
||||
else env['ir.attachment'])
|
||||
mask_refs = [{
|
||||
'id': a.id,
|
||||
'name': a.name or '',
|
||||
'mimetype': a.mimetype or '',
|
||||
'is_image': (a.mimetype or '').startswith('image/'),
|
||||
} for a in mask_atts]
|
||||
steps.append({
|
||||
'id': step.id,
|
||||
'sequence': step.sequence,
|
||||
@@ -75,6 +87,8 @@ class FpWorkspaceController(http.Controller):
|
||||
'name': step.name or '',
|
||||
'kind': step.kind or 'other',
|
||||
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
|
||||
# Drives the embedded rack-split panel inside this step's row.
|
||||
'is_racking': step.area_kind == 'racking',
|
||||
'state': step.state,
|
||||
# Partial-order handling — parts currently parked at this
|
||||
# step. Drives the "Send to next" button visibility + the
|
||||
@@ -112,6 +126,7 @@ class FpWorkspaceController(http.Controller):
|
||||
'quick_look_prompt_count': len(
|
||||
getattr(step, 'quick_look_prompt_ids', step.browse())
|
||||
),
|
||||
'masking_refs': mask_refs,
|
||||
})
|
||||
|
||||
# ---- Spec + attachments + chatter -------------------------------
|
||||
@@ -288,6 +303,9 @@ class FpWorkspaceController(http.Controller):
|
||||
'is_manager': env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
),
|
||||
# Note: the rack-split panel is gated per-step via each step's
|
||||
# 'is_racking' flag (area_kind == 'racking'), embedded in the
|
||||
# racking step's row — not a job-level panel.
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @odoo-module **/
|
||||
// Racking panel — split a WO's parts across multiple racks (Phase 1).
|
||||
// Lives on the Job Workspace, shown when the WO is at the Racking step.
|
||||
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: "", busy: false });
|
||||
onWillStart(() => this.reload());
|
||||
}
|
||||
|
||||
_apply(d) {
|
||||
if (d && d.ok) {
|
||||
this.state.data = d;
|
||||
this.state.error = "";
|
||||
} else {
|
||||
this.state.error = (d && d.error) || "Something went wrong.";
|
||||
}
|
||||
}
|
||||
|
||||
async _call(route, params) {
|
||||
if (this.state.busy) {
|
||||
return;
|
||||
}
|
||||
this.state.busy = true;
|
||||
try {
|
||||
this._apply(await rpc(route, params));
|
||||
} finally {
|
||||
this.state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
reload() {
|
||||
return this._call("/fp/racking/load", { job_id: this.props.jobId });
|
||||
}
|
||||
addRack() {
|
||||
return this._call("/fp/racking/add_rack", { job_id: this.props.jobId });
|
||||
}
|
||||
divideEqually() {
|
||||
return this._call("/fp/racking/divide_equally", { job_id: this.props.jobId });
|
||||
}
|
||||
setQty(load, ev) {
|
||||
const qty = parseInt(ev.target.value, 10) || 0;
|
||||
return this._call("/fp/racking/set_qty", { load_id: load.id, qty });
|
||||
}
|
||||
removeRack(load) {
|
||||
return this._call("/fp/racking/remove_rack", { load_id: load.id });
|
||||
}
|
||||
}
|
||||
@@ -30,18 +30,23 @@ import { FpTabletLock } from "./tablet_lock";
|
||||
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||||
import { FpDamageDialog } from "./fp_damage_dialog";
|
||||
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
||||
import { RackingPanel } from "./components/racking_panel";
|
||||
import { FpMovePartsDialog } from "./move_parts_dialog";
|
||||
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
|
||||
import { FileModel } from "@web/core/file_viewer/file_model";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, FpMovePartsDialog };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.dialog = useService("dialog");
|
||||
this.tabletSessionManager = useService("fp_tablet_session_manager");
|
||||
// Full-screen image/PDF viewer (zoom + swipe) for masking refs.
|
||||
this.fileViewer = useFileViewer();
|
||||
|
||||
this.state = useState({
|
||||
data: null,
|
||||
@@ -69,7 +74,7 @@ export class FpJobWorkspace extends Component {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_plant_kanban",
|
||||
target: "current",
|
||||
target: "main",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -82,7 +87,7 @@ export class FpJobWorkspace extends Component {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_plant_kanban",
|
||||
target: "current",
|
||||
target: "main",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -164,16 +169,16 @@ export class FpJobWorkspace extends Component {
|
||||
|
||||
// ---- Navigation --------------------------------------------------------
|
||||
onBack() {
|
||||
// The workspace is opened with target: "current" which REPLACES
|
||||
// the current action and wipes the backstack. Navigate explicitly
|
||||
// to the plant kanban — the sole Shop Floor surface as of
|
||||
// 2026-05-25 (fp_shopfloor_landing was retired the same day).
|
||||
// See CLAUDE.md Critical Rule 21 + the "Legacy-action redirect"
|
||||
// section.
|
||||
// target: "main" CLEARS the breadcrumb stack (Odoo 19:
|
||||
// action.target === "main" => clearBreadcrumbs in action_service.js).
|
||||
// target: "current" was APPENDING — each kanban<->workspace switch
|
||||
// grew the /odoo/... URL, and lock/unlock window.location.reload()
|
||||
// preserved it, so the address bar ballooned. "main" keeps the URL a
|
||||
// single action. The plant kanban is the sole Shop Floor surface.
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_plant_kanban",
|
||||
target: "current",
|
||||
target: "main",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,6 +204,24 @@ export class FpJobWorkspace extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
// Open masking reference image(s)/PDF(s) in the full-screen viewer.
|
||||
// Builds FileModel descriptors so the operator gets zoom + swipe across
|
||||
// every reference on this step, starting at the tile they tapped.
|
||||
openMaskRef(step, ref) {
|
||||
const files = (step.masking_refs || []).map((r) => {
|
||||
const f = new FileModel();
|
||||
f.id = r.id;
|
||||
f.name = r.name;
|
||||
f.mimetype = r.mimetype;
|
||||
f.type = "binary";
|
||||
return f;
|
||||
});
|
||||
const clicked = files.find((f) => f.id === ref.id) || files[0];
|
||||
if (clicked) {
|
||||
this.fileViewer.open(clicked, files);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step state helpers ------------------------------------------------
|
||||
iconForStepState(state) {
|
||||
const map = {
|
||||
@@ -527,6 +550,17 @@ export class FpJobWorkspace extends Component {
|
||||
|
||||
async onReceivingBoxCountBlur(rcv, ev) {
|
||||
const newVal = parseInt(ev.target.value, 10) || 0;
|
||||
await this._saveReceivingBoxCount(rcv, newVal);
|
||||
}
|
||||
|
||||
// +/- stepper on the Boxes-received field. Clamps at 0 and reuses the
|
||||
// same persist path as the typed-in blur handler.
|
||||
async onReceivingBoxStep(rcv, delta) {
|
||||
const newVal = Math.max(0, (rcv.box_count_in || 0) + delta);
|
||||
await this._saveReceivingBoxCount(rcv, newVal);
|
||||
}
|
||||
|
||||
async _saveReceivingBoxCount(rcv, newVal) {
|
||||
if (newVal === (rcv.box_count_in || 0)) return;
|
||||
rcv.box_count_in = newVal;
|
||||
try {
|
||||
|
||||
@@ -212,7 +212,7 @@ export class FpPlantKanban extends Component {
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
params: { job_id: res.id },
|
||||
target: "current",
|
||||
target: "main",
|
||||
});
|
||||
return; // navigating away — skip the refresh
|
||||
} else if (res.model === "fp.job.step") {
|
||||
@@ -223,7 +223,7 @@ export class FpPlantKanban extends Component {
|
||||
job_id: res.job_id || 0,
|
||||
focus_step_id: res.id,
|
||||
},
|
||||
target: "current",
|
||||
target: "main",
|
||||
});
|
||||
return;
|
||||
} else if (res.action_tag) {
|
||||
@@ -232,7 +232,7 @@ export class FpPlantKanban extends Component {
|
||||
type: "ir.actions.client",
|
||||
tag: res.action_tag,
|
||||
params: res.action_params || {},
|
||||
target: "current",
|
||||
target: "main",
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Racking panel (Job Workspace) — split a WO across racks. Self-contained
|
||||
// tokens with a compile-time dark-mode branch (Odoo 19 compiles this file
|
||||
// into both web.assets_backend and web.assets_web_dark).
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_rkp-card-hex: #ffffff;
|
||||
$_rkp-border-hex: #d8dadd;
|
||||
$_rkp-text-hex: #1d1f1e;
|
||||
$_rkp-page-hex: #f3f4f6;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_rkp-card-hex: #22262d !global;
|
||||
$_rkp-border-hex: #3a3f47 !global;
|
||||
$_rkp-text-hex: #e6e6e6 !global;
|
||||
$_rkp-page-hex: #1a1d21 !global;
|
||||
}
|
||||
|
||||
.o_fp_racking_panel {
|
||||
background: $_rkp-card-hex;
|
||||
border: 1px solid $_rkp-border-hex;
|
||||
border-left: 4px solid #0071e3;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
margin-bottom: 0.7rem;
|
||||
color: $_rkp-text-hex;
|
||||
|
||||
.o_fp_rkp_head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.o_fp_rkp_title { font-weight: 700; }
|
||||
.o_fp_rkp_unassigned {
|
||||
font-size: 0.85rem;
|
||||
color: #1d6e2f;
|
||||
&.has { color: #b06600; font-weight: 600; }
|
||||
}
|
||||
.o_fp_rkp_err {
|
||||
color: #b00018;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.o_fp_rkp_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid $_rkp-border-hex;
|
||||
&:last-of-type { border-bottom: 0; }
|
||||
&.over { border-left: 3px solid #ff9f0a; padding-left: 0.4rem; }
|
||||
}
|
||||
.o_fp_rkp_rk { flex: 1; font-weight: 600; }
|
||||
.o_fp_rkp_qty {
|
||||
width: 6rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
background: $_rkp-page-hex;
|
||||
}
|
||||
.o_fp_rkp_cap { color: #888; font-size: 0.85rem; }
|
||||
.o_fp_rkp_actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -219,19 +219,38 @@ $_ws-text-hex: #1d1d1f;
|
||||
grid-template-columns: 1.7fr 1fr;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 900px) { grid-template-columns: 1fr; }
|
||||
// Single column on tablets/phones, and make MAIN itself the one scroll
|
||||
// container — the work (steps/receiving) sits at the top, Notes stack
|
||||
// below and scroll into view when needed. The old layout kept
|
||||
// overflow:hidden with two nested auto-height scroll panes, which
|
||||
// clipped the notes and broke scrolling on narrow screens.
|
||||
@media (max-width: 900px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_steps {
|
||||
padding: 0.7rem 1rem;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid $_ws-border-hex;
|
||||
|
||||
// MAIN owns the scroll on narrow screens; don't nest a second one.
|
||||
@media (max-width: 900px) {
|
||||
overflow-y: visible;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_side {
|
||||
padding: 0.7rem 1rem;
|
||||
overflow-y: auto;
|
||||
background: $_ws-page-hex;
|
||||
|
||||
@media (max-width: 900px) { overflow-y: visible; }
|
||||
}
|
||||
|
||||
.o_fp_ws_empty {
|
||||
@@ -298,6 +317,89 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--bs-secondary-color, #555); font-style: italic; }
|
||||
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||
|
||||
// ---- Masking reference tiles (tap → full-screen FileViewer) -----------
|
||||
.o_fp_ws_mask_refs {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid #d97706;
|
||||
border-left: 4px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
.o_fp_ws_mask_refs_label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #b06600;
|
||||
margin-bottom: 0.4rem;
|
||||
i { margin-right: 0.3rem; }
|
||||
}
|
||||
.o_fp_ws_mask_refs_grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.o_fp_ws_mask_ref {
|
||||
position: relative;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: $_ws-card-hex;
|
||||
transition: transform 0.08s ease, box-shadow 0.08s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
}
|
||||
.o_fp_ws_mask_ref_thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.o_fp_ws_mask_ref_pdf {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem;
|
||||
text-align: center;
|
||||
|
||||
i { font-size: 1.8rem; color: #d9534f; }
|
||||
}
|
||||
.o_fp_ws_mask_ref_pdfname {
|
||||
font-size: 0.6rem;
|
||||
color: $_ws-text-hex;
|
||||
line-height: 1.1;
|
||||
word-break: break-word;
|
||||
max-height: 2.2em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.o_fp_ws_mask_ref_zoom {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_ws_mask_refs_label { color: #f0a93a; }
|
||||
}
|
||||
|
||||
.o_fp_ws_step_excluded {
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color, #888);
|
||||
@@ -385,6 +487,61 @@ $_ws-text-hex: #1d1d1f;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// ===== Phone optimization (2026-06-02) ==============================
|
||||
// Shrink the fixed chrome (header + workflow bar + rail) so the operator
|
||||
// sees the actual work (receiving / step cards) without scrolling. Notes
|
||||
// sit below the work in the single scroll column — present, scroll for more.
|
||||
@media (max-width: 600px) {
|
||||
.o_fp_ws_head {
|
||||
padding: 0.45rem 0.7rem;
|
||||
gap: 0.4rem 0.6rem;
|
||||
}
|
||||
.o_fp_ws_head_l, .o_fp_ws_head_r { gap: 0.35rem; }
|
||||
.o_fp_ws_wo { font-size: 1.05rem; }
|
||||
.o_fp_ws_cust, .o_fp_ws_part { font-size: 0.85rem; }
|
||||
.o_fp_ws_back, .o_fp_ws_handoff { padding: 0.4rem 0.7rem; font-size: 0.85rem; }
|
||||
.o_fp_ws_pill { padding: 0.2rem 0.5rem; font-size: 0.76rem; }
|
||||
|
||||
// Workflow bar: tighter + horizontally scrollable so every stage is
|
||||
// reachable (it was clipped) while taking far less vertical room.
|
||||
.o_fp_ws_bar { padding: 0.4rem 0.7rem; gap: 0.5rem; }
|
||||
.o_fp_ws_bar_line {
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.o_fp_ws_dot_wrap { min-width: 56px; }
|
||||
.o_fp_ws_dot_wrap .o_fp_ws_bar_dot { width: 14px; height: 14px; }
|
||||
.o_fp_ws_dot_wrap .o_fp_ws_bar_label { font-size: 0.66rem; margin-top: 0.2rem; }
|
||||
.o_fp_ws_next { white-space: nowrap; }
|
||||
|
||||
// Work + notes stack tighter; rail stays tappable but compact.
|
||||
.o_fp_ws_steps, .o_fp_ws_side { padding: 0.5rem 0.6rem; }
|
||||
.o_fp_ws_rail { padding: 0.45rem 0.6rem; gap: 0.4rem; }
|
||||
|
||||
// Receiving card part lines: stack vertically so the Received input and
|
||||
// the Good/Damaged select wrap INSIDE the card instead of overflowing
|
||||
// off the right edge. The part description now wraps across full width.
|
||||
.o_fp_ws_rcv { padding: 0.7rem; }
|
||||
.o_fp_ws_rcv_line {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.o_fp_ws_rcv_line_part { min-width: 0; overflow-wrap: anywhere; }
|
||||
.o_fp_ws_rcv_line_qty {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
.o_fp_ws_rcv_line_qty label { flex: 1 1 8rem; }
|
||||
.o_fp_ws_rcv_qty_input { width: auto; flex: 1 1 4rem; min-width: 3.5rem; }
|
||||
.o_fp_ws_rcv_cond_select { width: auto; flex: 1 1 7rem; min-width: 6rem; }
|
||||
|
||||
// Shipping panel fields already wrap; keep them inside the card too.
|
||||
.o_fp_ws_ship_fields label { min-width: 0; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SHIPPING PANEL (tablet receiving+shipping 2026-05-29)
|
||||
// =============================================================================
|
||||
@@ -519,6 +676,45 @@ $_ws-text-hex: #1d1d1f;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// +/- stepper around the Boxes-received input (and any future numeric step
|
||||
// field). Big touch targets; the input grows to fill between the buttons.
|
||||
.o_fp_ws_stepper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
max-width: 18rem;
|
||||
|
||||
.o_fp_ws_rcv_box_input {
|
||||
flex: 1 1 auto;
|
||||
max-width: none; // override the standalone 12rem cap inside the stepper
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_stepper_btn {
|
||||
flex: 0 0 auto;
|
||||
width: 3rem;
|
||||
min-height: 3rem; // comfortable touch target
|
||||
border: 1px solid $_ws-border-hex;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%);
|
||||
color: $_ws-text-hex;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.1s ease, background 0.1s ease, box-shadow 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
&:active { transform: scale(0.95); }
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_lines {
|
||||
background: $_ws-page-hex;
|
||||
border-radius: 6px;
|
||||
@@ -806,20 +1002,25 @@ $_ws-text-hex: #1d1d1f;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// NOTE: Odoo's backend CSS does NOT define --bs-body-color /
|
||||
// --bs-secondary-color / --bs-*-bg as custom properties (verified: 0
|
||||
// definitions in the compiled bundle — they're SCSS literals + two
|
||||
// bundles + [data-bs-theme]). So var(--bs-body-color, #hex) ALWAYS
|
||||
// resolves to the dark #hex fallback, in light AND dark mode. The fix
|
||||
// for dialog text is to INHERIT the modal's theme-correct colour (the
|
||||
// dialog title and the "Count the Parts" list items do exactly this and
|
||||
// are readable in both modes). Tinted boxes use translucent rgba() so
|
||||
// they work over whatever the live theme background is.
|
||||
.o_fp_finish_block_step {
|
||||
font-size: 1.1rem;
|
||||
color: var(--bs-body-color);
|
||||
// Amber wash over the live theme bg — pale in light mode, dark-amber
|
||||
// in dark mode (the -bg-subtle/-text-emphasis BS vars aren't defined
|
||||
// in Odoo's bootstrap, so color-mix is the dark-aware path).
|
||||
background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg));
|
||||
background-color: rgba(245, 158, 11, 0.16);
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_msg {
|
||||
color: var(--bs-secondary-color, #333);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_list {
|
||||
@@ -834,9 +1035,9 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_action_note {
|
||||
color: var(--bs-secondary-color, #555);
|
||||
// Inherit text colour; translucent neutral box works in both themes.
|
||||
font-style: italic;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--bs-tertiary-bg, #f3f4f6);
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
.o_fp_plant_kanban {
|
||||
padding: 8px;
|
||||
background: $plant-bg;
|
||||
// Full viewport height + flex column so .board can grow to fill all
|
||||
// remaining vertical space. min-height: 100vh would let .board's
|
||||
// intrinsic height bubble up and put the horizontal scrollbar
|
||||
// mid-page; height + flex pins the scrollbar to the viewport bottom.
|
||||
height: 100vh;
|
||||
// Fill the Odoo action area (below the navbar) and own the scroll
|
||||
// internally — NOT 100vh. 100vh is taller than the available area by
|
||||
// the navbar height, so the board bottom + its horizontal scrollbar
|
||||
// overflowed off-screen and scrolling broke — badly on phones, where
|
||||
// Odoo also re-lays-out at the md breakpoint and the scroll gets lost
|
||||
// up the tree. height:100% + internal overflow is the same pattern
|
||||
// job_workspace / manager_dashboard / .o_fp_tablet use. flex column so
|
||||
// .board fills the remaining space under the sticky header.
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: $plant-text;
|
||||
@@ -119,7 +124,26 @@
|
||||
|
||||
// 8 tiles — Work Orders, At My Station, Bakes Due, On Hold,
|
||||
// Awaiting QC, Awaiting CoC, Ready to Ship, Overdue.
|
||||
.kpi-strip { display: grid; grid-template-columns: repeat(8, 1fr); gap: 8px; }
|
||||
.kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 8px;
|
||||
// 8 tiles can't fit a narrow row. Drop to 4-up on tablets, then on
|
||||
// phones make it one horizontally-scrollable row so the header stays
|
||||
// short and the board keeps the screen.
|
||||
@media (max-width: 1180px) { grid-template-columns: repeat(4, 1fr); }
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: none;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 42%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
padding-bottom: 2px;
|
||||
> * { scroll-snap-align: start; }
|
||||
}
|
||||
}
|
||||
|
||||
.search-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.search-input {
|
||||
@@ -156,7 +180,23 @@
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden; // columns scroll internally, not the board
|
||||
overscroll-behavior-x: contain; // don't bounce the whole page sideways
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px; // room for the horizontal scrollbar
|
||||
// Tablet: slightly narrower columns so more fit per swipe.
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(9, minmax(260px, 1fr));
|
||||
}
|
||||
// Phone: one full-width stage per screen; swipe between stages.
|
||||
// 86vw leaves a peek of the next column so it's clearly scrollable.
|
||||
// Cards are width:100% so they never overflow the narrower column.
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: repeat(9, 86vw);
|
||||
gap: 10px;
|
||||
scroll-snap-type: x proximity;
|
||||
> .col { scroll-snap-align: start; }
|
||||
}
|
||||
}
|
||||
// Each .col is now a proper bordered card that runs full board
|
||||
// height — same visual treatment as Trello / Asana columns. The
|
||||
@@ -236,3 +276,29 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Responsive — phones / small screens (2026-06-02) ==============
|
||||
// Compact the header so the board keeps the screen, and make the toolbar
|
||||
// controls full-width + tappable (>=40px) on a phone.
|
||||
.o_fp_plant_kanban {
|
||||
@media (max-width: 600px) {
|
||||
padding: 6px;
|
||||
|
||||
.floor-header { padding: 8px; margin-bottom: 6px; gap: 6px; }
|
||||
.floor-title { font-size: 15px; }
|
||||
|
||||
.floor-header-top { gap: 8px; }
|
||||
.floor-controls { gap: 5px; width: 100%; }
|
||||
|
||||
// 3-segment mode toggle spans the row; each segment stays tappable.
|
||||
.mode-toggle { flex: 1 1 100%; }
|
||||
.mode-toggle .mode-btn { flex: 1 1 0; padding: 10px 6px; }
|
||||
|
||||
.station-picker,
|
||||
.toolbar-btn { padding: 10px 12px; }
|
||||
|
||||
// Search fills its own row; filter chips wrap underneath.
|
||||
.search-row { gap: 6px; }
|
||||
.search-input { min-width: 0; flex: 1 1 100%; }
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user